Refactor Listener to listener and update related methods

Kim committed Apr 14, 2026 at 19:02 UTC 9603e429b1c10b7039a6937a4bf2b07dbc38ae4a
7 files changed +492 -521
cmd/portal-tunnel/README.md
+1 -1
@@ -125,7 +125,7 @@ Legacy execution compatibility has been removed:
125 - The tunnel starts once relay URLs pass local validation. Remote compatibility checks, lease registration, and reconnects continue in the background until each relay becomes ready.
126 - With discovery enabled, the tunnel uses the public registry as discovery seed input and can expand through relay discovery. Explicit `--relays` values are always included separately from the auto-selected relay pool. With `--discovery=false`, only the explicit relay URLs are used. Published public URLs appear only for relays that have registered successfully.
127 - Explicit `--relays` listeners retry indefinitely with `RetryCount=0`. Auto-selected discovery relays are created with `RetryCount=10` and are dropped from the active set after that budget is exhausted.
128 -- `ListenerConfig.RetryCount` limits retries when positive. `RetryCount=0` retries indefinitely.
128 +- Retry count limits retries when positive. `RetryCount=0` retries indefinitely.
129 - Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
130 - `portal expose` enables MITM strict enforcement by default. Use `--ban-mitm=false` to keep warning-only behavior when the TLS self-probe suspects relay termination.
131 - When the local service is unreachable, the tunnel returns an HTTP 503 page.
sdk/api_client.go
+120 -182
@@ -11,9 +11,7 @@ import (
11 "io"
12 "net"
13 "net/http"
14 - "net/url"
14 "strings"
16 - "sync"
15 "time"
16
17 "github.com/quic-go/quic-go"
@@ -35,189 +33,145 @@ const (
33
34 var errRelayIncompatible = errors.New("relay is incompatible")
35
38 -type apiClient struct {
39 - mu sync.RWMutex
40 - baseURL *url.URL
41 - httpClient *http.Client
42 - rawTLSConfig *tls.Config
43 - dialTimeout time.Duration
44 - requestTimeout time.Duration
45 - identity types.Identity
46 - accessToken string
47 - expiresAt time.Time
48 - metadata types.LeaseMetadata
49 - resolvedPublicIP string
50 - sniPort int
51 -}
52 -
53 -func newApiClient(relayURL string, cfg ListenerConfig) (*apiClient, error) {
54 - normalizedRelayURL, err := utils.NormalizeRelayURL(relayURL)
55 - if err != nil {
56 - return nil, err
57 - }
58 -
59 - baseURL, err := url.Parse(normalizedRelayURL)
60 - if err != nil {
61 - return nil, fmt.Errorf("parse relay url: %w", err)
62 - }
63 -
64 - dialTimeout := utils.DurationOrDefault(cfg.DialTimeout, defaultDialTimeout)
65 - requestTimeout := utils.DurationOrDefault(cfg.RequestTimeout, defaultRequestTimeout)
66 -
67 - return &apiClient{
68 - baseURL: baseURL,
69 - dialTimeout: dialTimeout,
70 - requestTimeout: requestTimeout,
71 - identity: cfg.Identity.Copy(),
72 - metadata: cfg.Metadata.Copy(),
73 - }, nil
74 -}
75 -
76 -func closeIdleHTTPClient(httpClient *http.Client) {
77 - if httpClient == nil {
36 +func closeIdleHTTPClient(controlHTTPClient *http.Client) {
37 + if controlHTTPClient == nil {
38 return
39 }
80 - if transport, ok := httpClient.Transport.(*http.Transport); ok {
40 + if transport, ok := controlHTTPClient.Transport.(*http.Transport); ok {
41 transport.CloseIdleConnections()
42 }
43 }
44
85 -func (a *apiClient) close() {
86 - if a == nil {
87 - return
88 - }
89 - a.mu.RLock()
90 - httpClient := a.httpClient
91 - a.mu.RUnlock()
92 - closeIdleHTTPClient(httpClient)
93 -}
94 -
45 // resetTransport tears down the cached HTTP client and TLS config so the next
46 // API call creates fresh TCP connections. Call this after detecting a system
47 // sleep/wake cycle where pooled connections are almost certainly dead.
98 -func (a *apiClient) resetTransport() {
99 - if a == nil {
48 +func (l *listener) resetTransport() {
49 + if l == nil {
50 return
51 }
102 - a.mu.Lock()
103 - httpClient := a.httpClient
104 - a.httpClient = nil
105 - a.rawTLSConfig = nil
106 - a.mu.Unlock()
107 - closeIdleHTTPClient(httpClient)
52 + l.mu.Lock()
53 + controlHTTPClient := l.controlHTTPClient
54 + l.controlHTTPClient = nil
55 + l.controlTLSConfig = nil
56 + l.mu.Unlock()
57 + closeIdleHTTPClient(controlHTTPClient)
58 }
59
110 -func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEnabled, tcpEnabled bool) (types.RegisterResponse, error) {
111 - if err := a.ensureHTTPClient(ctx); err != nil {
60 +func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnabled, tcpEnabled bool) (types.RegisterResponse, error) {
61 + if err := l.ensureControlHTTPClient(ctx); err != nil {
62 return types.RegisterResponse{}, err
63 }
114 - a.mu.RLock()
115 - httpClient := a.httpClient
116 - a.mu.RUnlock()
117 - if httpClient == nil {
64 + l.mu.Lock()
65 + controlHTTPClient := l.controlHTTPClient
66 + relayURL := l.relayURL
67 + identity := l.identity.Copy()
68 + metadata := l.metadata.Copy()
69 + l.mu.Unlock()
70 + if controlHTTPClient == nil {
71 return types.RegisterResponse{}, errors.New("relay http client is unavailable")
72 }
73
74 var challenge types.RegisterChallengeResponse
75 challengeReq := types.RegisterChallengeRequest{
123 - Identity: a.identity.Copy(),
124 - Metadata: a.metadata.Copy(),
76 + Identity: identity,
77 + Metadata: metadata,
78 TTL: int(ttl / time.Second),
79 UDPEnabled: udpEnabled,
80 TCPEnabled: tcpEnabled,
81 }
129 - if err := utils.HTTPDoAPIPath(ctx, httpClient, a.baseURL, http.MethodPost, types.PathSDKRegisterChallenge, challengeReq, nil, &challenge); err != nil {
82 + if err := utils.HTTPDoAPIPath(ctx, controlHTTPClient, relayURL, http.MethodPost, types.PathSDKRegisterChallenge, challengeReq, nil, &challenge); err != nil {
83 return types.RegisterResponse{}, err
84 }
85
133 - signature, err := utils.SignEthereumPersonalMessage(challenge.SIWEMessage, a.identity.PrivateKey)
86 + signature, err := utils.SignEthereumPersonalMessage(challenge.SIWEMessage, identity.PrivateKey)
87 if err != nil {
88 return types.RegisterResponse{}, err
89 }
90
91 var resp types.RegisterResponse
139 - if err := utils.HTTPDoAPIPath(ctx, httpClient, a.baseURL, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{
92 + if err := utils.HTTPDoAPIPath(ctx, controlHTTPClient, relayURL, http.MethodPost, types.PathSDKRegister, types.RegisterRequest{
93 ChallengeID: challenge.ChallengeID,
94 SIWEMessage: challenge.SIWEMessage,
95 SIWESignature: signature,
143 - ReportedIP: a.reportedIP(ctx),
96 + ReportedIP: l.reportedIP(ctx),
97 }, nil, &resp); err != nil {
98 return types.RegisterResponse{}, err
99 }
147 - resp.AccessToken = strings.TrimSpace(resp.AccessToken)
148 - if resp.AccessToken == "" {
149 - return types.RegisterResponse{}, errors.New("relay did not return access token")
150 - }
151 - registeredIdentity, err := utils.NormalizeIdentity(resp.Identity)
152 - if err != nil {
153 - return types.RegisterResponse{}, err
154 - }
155 - if registeredIdentity.Key() != a.identity.Key() {
156 - return types.RegisterResponse{}, errors.New("relay returned mismatched lease identity")
157 - }
158 - resp.Identity = registeredIdentity
159 -
160 - sniPort := 0
161 - if udpEnabled {
162 - if resp.SNIPort <= 0 {
163 - return types.RegisterResponse{}, errors.New("relay did not return sni port for udp transport")
164 - }
165 - sniPort = resp.SNIPort
166 - }
167 -
168 - a.mu.Lock()
169 - a.accessToken = resp.AccessToken
170 - a.expiresAt = resp.ExpiresAt
171 - a.sniPort = sniPort
172 - a.mu.Unlock()
100 return resp, nil
101 }
102
176 -func (a *apiClient) ensureHTTPClient(ctx context.Context) error {
177 - a.mu.RLock()
178 - if a.httpClient != nil && a.rawTLSConfig != nil {
179 - a.mu.RUnlock()
103 +func (l *listener) ensureControlHTTPClient(ctx context.Context) error {
104 + if l == nil {
105 + return errors.New("listener is unavailable")
106 + }
107 + l.mu.Lock()
108 + if l.controlHTTPClient != nil && l.controlTLSConfig != nil {
109 + l.mu.Unlock()
110 return nil
111 }
182 - a.mu.RUnlock()
112 + relayURL := l.relayURL
113 + requestTimeout := l.requestTimeout
114 + l.mu.Unlock()
115 + if relayURL == nil {
116 + return errors.New("relay url is unavailable")
117 + }
118
119 bootstrapCtx, cancel := context.WithTimeout(ctx, defaultDialTimeout+defaultHandshakeTimeout)
120 defer cancel()
121
187 - rawTLSConfig, httpClient, err := utils.NewHTTPTLSClient(bootstrapCtx, a.baseURL, a.requestTimeout)
122 + controlTLSConfig, controlHTTPClient, err := utils.NewHTTPTLSClient(bootstrapCtx, relayURL, requestTimeout)
123 if err != nil {
124 return err
125 }
191 - if err := a.ensureCompatible(ctx, httpClient); err != nil {
192 - closeIdleHTTPClient(httpClient)
126 + if err := l.ensureCompatible(ctx, controlHTTPClient); err != nil {
127 + closeIdleHTTPClient(controlHTTPClient)
128 return err
129 }
130
196 - a.mu.Lock()
197 - if a.httpClient != nil && a.rawTLSConfig != nil {
198 - a.mu.Unlock()
199 - closeIdleHTTPClient(httpClient)
131 + l.mu.Lock()
132 + if l.controlHTTPClient != nil && l.controlTLSConfig != nil {
133 + l.mu.Unlock()
134 + closeIdleHTTPClient(controlHTTPClient)
135 return nil
136 }
202 - oldHTTPClient := a.httpClient
203 - a.httpClient = httpClient
204 - a.rawTLSConfig = rawTLSConfig
205 - a.mu.Unlock()
206 - closeIdleHTTPClient(oldHTTPClient)
137 + oldControlHTTPClient := l.controlHTTPClient
138 + l.controlHTTPClient = controlHTTPClient
139 + l.controlTLSConfig = controlTLSConfig
140 + l.mu.Unlock()
141 + closeIdleHTTPClient(oldControlHTTPClient)
142
143 return nil
144 }
145
211 -func (a *apiClient) reportedIP(ctx context.Context) string {
212 - if a.resolvedPublicIP == "" {
213 - a.resolvedPublicIP = utils.ResolvePublicIP(ctx)
146 +func (l *listener) reportedIP(ctx context.Context) string {
147 + l.mu.Lock()
148 + if l.resolvedPublicIP != "" {
149 + ip := l.resolvedPublicIP
150 + l.mu.Unlock()
151 + return ip
152 + }
153 + l.mu.Unlock()
154 +
155 + ip := utils.ResolvePublicIP(ctx)
156 + l.mu.Lock()
157 + if l.resolvedPublicIP == "" {
158 + l.resolvedPublicIP = ip
159 }
215 - return a.resolvedPublicIP
160 + ip = l.resolvedPublicIP
161 + l.mu.Unlock()
162 + return ip
163 }
164
218 -func (a *apiClient) ensureCompatible(ctx context.Context, httpClient *http.Client) error {
165 +func (l *listener) ensureCompatible(ctx context.Context, controlHTTPClient *http.Client) error {
166 + l.mu.Lock()
167 + relayURL := l.relayURL
168 + l.mu.Unlock()
169 + if relayURL == nil {
170 + return errors.New("relay url is unavailable")
171 + }
172 +
173 var resp types.DomainResponse
220 - if err := utils.HTTPDoAPIPath(ctx, httpClient, a.baseURL, http.MethodGet, types.PathSDKDomain, nil, nil, &resp); err != nil {
174 + if err := utils.HTTPDoAPIPath(ctx, controlHTTPClient, relayURL, http.MethodGet, types.PathSDKDomain, nil, nil, &resp); err != nil {
175 err = fmt.Errorf("check relay compatibility: %w", err)
176 var netErr net.Error
177 var apiErr *types.APIRequestError
@@ -236,86 +190,73 @@ func (a *apiClient) ensureCompatible(ctx context.Context, httpClient *http.Clien
190 return nil
191 }
192
239 -func (a *apiClient) renewLease(ctx context.Context, ttl time.Duration) error {
240 - if err := a.ensureHTTPClient(ctx); err != nil {
241 - return err
193 +func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration, accessToken string) (types.RenewResponse, error) {
194 + if err := l.ensureControlHTTPClient(ctx); err != nil {
195 + return types.RenewResponse{}, err
196 }
197
244 - a.mu.RLock()
245 - httpClient := a.httpClient
246 - accessToken := a.accessToken
247 - a.mu.RUnlock()
248 - if httpClient == nil {
249 - return errors.New("relay http client is unavailable")
250 - }
251 - if strings.TrimSpace(accessToken) == "" {
252 - return errors.New("access token is not available")
198 + l.mu.Lock()
199 + controlHTTPClient := l.controlHTTPClient
200 + relayURL := l.relayURL
201 + l.mu.Unlock()
202 + if controlHTTPClient == nil {
203 + return types.RenewResponse{}, errors.New("relay http client is unavailable")
204 }
205
206 var resp types.RenewResponse
256 - if err := utils.HTTPDoAPIPath(ctx, httpClient, a.baseURL, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
207 + if err := utils.HTTPDoAPIPath(ctx, controlHTTPClient, relayURL, http.MethodPost, types.PathSDKRenew, types.RenewRequest{
208 AccessToken: accessToken,
209 TTL: int(ttl / time.Second),
259 - ReportedIP: a.reportedIP(ctx),
210 + ReportedIP: l.reportedIP(ctx),
211 }, nil, &resp); err != nil {
261 - return err
262 - }
263 - resp.AccessToken = strings.TrimSpace(resp.AccessToken)
264 - if resp.AccessToken == "" {
265 - return errors.New("relay did not return renewed access token")
212 + return types.RenewResponse{}, err
213 }
267 -
268 - a.mu.Lock()
269 - if a.accessToken == accessToken {
270 - a.accessToken = resp.AccessToken
271 - a.expiresAt = resp.ExpiresAt
272 - }
273 - a.mu.Unlock()
274 - return nil
214 + return resp, nil
215 }
216
277 -func (a *apiClient) unregisterLease(ctx context.Context) error {
278 - if err := a.ensureHTTPClient(ctx); err != nil {
217 +func (l *listener) unregisterLease(ctx context.Context, accessToken string) error {
218 + if err := l.ensureControlHTTPClient(ctx); err != nil {
219 return err
220 }
281 - a.mu.RLock()
282 - httpClient := a.httpClient
283 - accessToken := a.accessToken
284 - a.mu.RUnlock()
285 - if httpClient == nil {
221 + l.mu.Lock()
222 + controlHTTPClient := l.controlHTTPClient
223 + relayURL := l.relayURL
224 + l.mu.Unlock()
225 + if controlHTTPClient == nil {
226 return errors.New("relay http client is unavailable")
227 }
288 - return utils.HTTPDoAPIPath(ctx, httpClient, a.baseURL, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
228 + return utils.HTTPDoAPIPath(ctx, controlHTTPClient, relayURL, http.MethodPost, types.PathSDKUnregister, types.UnregisterRequest{
229 AccessToken: accessToken,
230 }, nil, nil)
231 }
232
293 -func (a *apiClient) openReverseSession(ctx context.Context) (net.Conn, error) {
294 - if err := a.ensureHTTPClient(ctx); err != nil {
233 +func (l *listener) openReverseSession(ctx context.Context, accessToken string) (net.Conn, error) {
234 + if err := l.ensureControlHTTPClient(ctx); err != nil {
235 return nil, err
236 }
297 - a.mu.RLock()
298 - rawTLSConfig := a.rawTLSConfig
299 - accessToken := a.accessToken
300 - a.mu.RUnlock()
301 - if rawTLSConfig == nil {
237 + l.mu.Lock()
238 + controlTLSConfig := l.controlTLSConfig
239 + relayURL := l.relayURL
240 + dialTimeout := l.dialTimeout
241 + l.mu.Unlock()
242 + if controlTLSConfig == nil {
243 return nil, errors.New("relay tls config is unavailable")
244 }
245
246 dialer := &tls.Dialer{
306 - NetDialer: &net.Dialer{Timeout: a.dialTimeout},
307 - Config: rawTLSConfig.Clone(),
247 + NetDialer: &net.Dialer{Timeout: dialTimeout},
248 + Config: controlTLSConfig.Clone(),
249 }
250
310 - conn, err := dialer.DialContext(ctx, "tcp", utils.EnsurePort(a.baseURL.Host))
251 + conn, err := dialer.DialContext(ctx, "tcp", utils.EnsurePort(relayURL.Host))
252 if err != nil {
253 return nil, err
254 }
255
256 req := &http.Request{
257 Method: http.MethodGet,
317 - URL: utils.ResolveAPIURL(a.baseURL, types.PathSDKConnect),
318 - Host: a.baseURL.Host,
258 + URL: utils.ResolveAPIURL(relayURL, types.PathSDKConnect),
259 + Host: relayURL.Host,
260 Header: make(http.Header),
261 }
262 req.Header.Set(types.HeaderAccessToken, accessToken)
@@ -367,20 +308,20 @@ func (c *bufferedConn) Read(p []byte) (int, error) {
308 }
309
310 // openQUICSession opens a QUIC connection to the relay for datagram transport.
370 -func (a *apiClient) openQUICSession(ctx context.Context, accessToken string) (*quic.Conn, error) {
371 - if err := a.ensureHTTPClient(ctx); err != nil {
311 +func (l *listener) openQUICSession(ctx context.Context, accessToken string, sniPort int) (*quic.Conn, error) {
312 + if err := l.ensureControlHTTPClient(ctx); err != nil {
313 return nil, err
314 }
315
375 - a.mu.RLock()
376 - rawTLSConfig := a.rawTLSConfig
377 - sniPort := a.sniPort
378 - a.mu.RUnlock()
379 - if rawTLSConfig == nil {
316 + l.mu.Lock()
317 + controlTLSConfig := l.controlTLSConfig
318 + relayURL := l.relayURL
319 + l.mu.Unlock()
320 + if controlTLSConfig == nil {
321 return nil, errors.New("relay tls config is unavailable")
322 }
323
383 - tlsConf := rawTLSConfig.Clone()
324 + tlsConf := controlTLSConfig.Clone()
325 tlsConf.NextProtos = []string{"portal-tunnel"}
326
327 quicConf := &quic.Config{
@@ -389,12 +330,9 @@ func (a *apiClient) openQUICSession(ctx context.Context, accessToken string) (*q
330 MaxIdleTimeout: 60 * time.Second,
331 }
332
392 - if sniPort <= 0 {
393 - return nil, errors.New("sni port is not available")
394 - }
395 - host := strings.TrimSpace(a.baseURL.Hostname())
333 + host := strings.TrimSpace(relayURL.Hostname())
334 if host == "" {
397 - host = strings.TrimSpace(a.baseURL.Host)
335 + host = strings.TrimSpace(relayURL.Host)
336 }
337 dialAddr := net.JoinHostPort(host, fmt.Sprintf("%d", sniPort))
338 conn, err := quic.DialAddr(ctx, dialAddr, tlsConf, quicConf)
sdk/expose.go
+14 -11
@@ -44,7 +44,7 @@ type Exposure struct {
44
45 relaySet *discovery.RelaySet
46 listenerMu sync.RWMutex
47 - relayListeners map[string]*Listener
47 + relayListeners map[string]*listener
48
49 closeOnce sync.Once
50 connSeq atomic.Uint64
@@ -119,7 +119,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
119 accepted: make(chan net.Conn, max(len(relayURLs)*defaultReadyTarget*2, 1)),
120 datagrams: make(chan types.DatagramFrame, max(len(relayURLs)*32, 1)),
121 relaySet: discovery.NewRelaySet(relayURLs),
122 - relayListeners: make(map[string]*Listener, len(relayURLs)),
122 + relayListeners: make(map[string]*listener, len(relayURLs)),
123 }
124
125 if len(relayURLs) > 0 {
@@ -187,7 +187,7 @@ func (e *Exposure) SendDatagram(frame types.DatagramFrame) error {
187 if listener == nil {
188 return net.ErrClosed
189 }
190 - return listener.SendDatagram(frame)
190 + return listener.sendDatagram(frame)
191 }
192
193 func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
@@ -208,7 +208,7 @@ func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
208 continue
209 }
210
211 - udpAddr, ready, pending := listener.DatagramReady()
211 + udpAddr, ready, pending := listener.datagramReady()
212 if ready {
213 if _, ok := seen[udpAddr]; !ok {
214 seen[udpAddr] = struct{}{}
@@ -318,7 +318,7 @@ func (e *Exposure) Close() error {
318
319 e.listenerMu.Lock()
320 relayListeners := e.relayListeners
321 - e.relayListeners = make(map[string]*Listener)
321 + e.relayListeners = make(map[string]*listener)
322 e.listenerMu.Unlock()
323
324 relayURLs := make([]string, 0, len(relayListeners))
@@ -376,7 +376,7 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
376 desiredRelayURLs := e.relaySet.PriorityRelays(clientState)
377
378 e.listenerMu.Lock()
379 - staleRelayListeners := make(map[string]*Listener)
379 + staleRelayListeners := make(map[string]*listener)
380 removedRelayURLs := make([]string, 0)
381 for relayURL, listener := range e.relayListeners {
382 if slices.Contains(desiredRelayURLs, relayURL) {
@@ -413,7 +413,7 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
413 if slices.Contains(e.explicitRelays, relayURL) {
414 retryCount = 0
415 }
416 - listener, err := NewListener(context.Background(), relayURL, ListenerConfig{
416 + listener, err := newListener(context.Background(), relayURL, listenerConfig{
417 Identity: e.identity.Copy(),
418 UDPEnabled: e.udpEnabled,
419 TCPEnabled: e.tcpEnabled,
@@ -460,16 +460,19 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
460 return nil
461 }
462
463 -func (e *Exposure) runListenerAcceptLoop(listener *Listener) {
463 +func (e *Exposure) runListenerAcceptLoop(listener *listener) {
464 if listener == nil {
465 return
466 }
467
468 - relayURL := listener.api.baseURL.String()
468 + relayURL := ""
469 + if listener.relayURL != nil {
470 + relayURL = listener.relayURL.String()
471 + }
472 if e.udpEnabled {
473 go func() {
474 for {
472 - frame, err := listener.AcceptDatagram()
475 + frame, err := listener.acceptDatagram()
476 if err != nil {
477 select {
478 case <-e.done:
@@ -482,7 +485,7 @@ func (e *Exposure) runListenerAcceptLoop(listener *Listener) {
485 log.Warn().
486 Err(err).
487 Str("relay_url", relayURL).
485 - Str("address", listener.Address()).
488 + Str("address", listener.address()).
489 Msg("datagram accept failed")
490 return
491 }
sdk/expose_test.go
+12 -12
@@ -29,17 +29,17 @@ func TestExposureReconcileRemovesBannedRelayFromActiveSet(t *testing.T) {
29
30 exposure := &Exposure{
31 relaySet: mustRelaySet(t, relayA, relayB),
32 - relayListeners: make(map[string]*Listener, 2),
32 + relayListeners: make(map[string]*listener, 2),
33 }
34 relayAClosed := make(chan struct{})
35 - exposure.relayListeners = map[string]*Listener{
35 + exposure.relayListeners = map[string]*listener{
36 relayA: {
37 - api: &apiClient{baseURL: relayURL},
38 - cancel: func() { close(relayAClosed) },
39 - doneCh: relayAClosed,
37 + relayURL: relayURL,
38 + cancel: func() { close(relayAClosed) },
39 + doneCh: relayAClosed,
40 },
41 relayB: {
42 - api: &apiClient{baseURL: relayBURL},
42 + relayURL: relayBURL,
43 },
44 }
45
@@ -84,16 +84,16 @@ func TestExposureReconcileRemovesStaleListener(t *testing.T) {
84 relayAClosed := make(chan struct{})
85 exposure := &Exposure{
86 relaySet: mustRelaySet(t, relayA, relayB),
87 - relayListeners: make(map[string]*Listener, 2),
87 + relayListeners: make(map[string]*listener, 2),
88 }
89 - exposure.relayListeners = map[string]*Listener{
89 + exposure.relayListeners = map[string]*listener{
90 relayA: {
91 - api: &apiClient{baseURL: relayAURL},
92 - cancel: func() { close(relayAClosed) },
93 - doneCh: relayAClosed,
91 + relayURL: relayAURL,
92 + cancel: func() { close(relayAClosed) },
93 + doneCh: relayAClosed,
94 },
95 relayB: {
96 - api: &apiClient{baseURL: relayBURL},
96 + relayURL: relayBURL,
97 },
98 }
99
sdk/listener.go
+296 -275
@@ -4,8 +4,10 @@ import (
4 "context"
5 "crypto/tls"
6 "errors"
7 + "fmt"
8 "io"
9 "net"
10 + "net/http"
11 "net/url"
12 "strings"
13 "sync"
@@ -20,7 +22,7 @@ import (
22 "github.com/gosuda/portal-tunnel/v2/utils"
23 )
24
23 -type ListenerConfig struct {
25 +type listenerConfig struct {
26 Identity types.Identity
27 UDPEnabled bool
28 TCPEnabled bool
@@ -37,8 +39,19 @@ type ListenerConfig struct {
39 relaySet *discovery.RelaySet
40 }
41
40 -type Listener struct {
41 - api *apiClient
42 +var errLeaseRefreshRequired = errors.New("lease refresh required")
43 +
44 +type listener struct {
45 + relayURL *url.URL
46 + controlHTTPClient *http.Client
47 + controlTLSConfig *tls.Config
48 + dialTimeout time.Duration
49 + requestTimeout time.Duration
50 + resolvedPublicIP string
51 + accessToken string
52 + expiresAt time.Time
53 + sniPort int
54 +
55 cancel context.CancelFunc
56 doneCh <-chan struct{}
57
@@ -52,10 +65,7 @@ type Listener struct {
65 datagram *transport.ClientDatagram
66 mitmManager *mitmManager
67
55 - registered chan struct{}
56 - closeOnce sync.Once
57 - registerOnce sync.Once
58 - streamCancel context.CancelFunc
68 + closeOnce sync.Once
69
70 banMITM bool
71 tcpEnabled bool
@@ -65,41 +75,50 @@ type Listener struct {
75 hostname string
76 udpAddr string
77 metadata types.LeaseMetadata
68 - tlsConfig *tls.Config
69 - tlsCloser io.Closer
78 +
79 + tenantTLSConfig *tls.Config
80 + tenantTLSCloser io.Closer
81 }
82
72 -// NewListener creates one relay listener and its dedicated relay transport for one relay URL.
83 +// newListener creates one relay listener and its dedicated relay transport for one relay URL.
84 // Only local config validation fails immediately; relay startup runs in the background until ready.
74 -func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Listener, error) {
85 +func newListener(ctx context.Context, relayURL string, cfg listenerConfig) (*listener, error) {
86 listenerCtx, cancel := context.WithCancel(ctx)
87 readyTarget := utils.IntOrDefault(cfg.ReadyTarget, defaultReadyTarget)
88 leaseTTL := utils.DurationOrDefault(cfg.LeaseTTL, defaultLeaseTTL)
89 + dialTimeout := utils.DurationOrDefault(cfg.DialTimeout, defaultDialTimeout)
90 + requestTimeout := utils.DurationOrDefault(cfg.RequestTimeout, defaultRequestTimeout)
91 handshakeTimeout := utils.DurationOrDefault(cfg.HandshakeTimeout, defaultHandshakeTimeout)
92 renewBefore := utils.DurationOrDefault(cfg.RenewBefore, defaultRenewBefore)
93 retryWait := utils.DurationOrDefault(cfg.RetryWait, defaultRetryWait)
94
82 - api, err := newApiClient(relayURL, cfg)
95 + normalizedRelayURL, err := utils.NormalizeRelayURL(relayURL)
96 if err != nil {
97 cancel()
98 return nil, err
99 }
87 -
88 - l := &Listener{
89 - doneCh: listenerCtx.Done(),
90 - cancel: cancel,
91 - api: api,
92 - registered: make(chan struct{}),
93 - readyTarget: readyTarget,
94 - retryCount: cfg.RetryCount,
95 - retryWait: retryWait,
96 - leaseTTL: leaseTTL,
97 - renewBefore: renewBefore,
98 - identity: api.identity.Copy(),
99 - metadata: cfg.Metadata.Copy(),
100 - banMITM: cfg.BanMITM,
101 - tcpEnabled: cfg.TCPEnabled,
102 - relaySet: cfg.relaySet,
100 + relayurl, err := url.Parse(normalizedRelayURL)
101 + if err != nil {
102 + cancel()
103 + return nil, fmt.Errorf("parse relay url: %w", err)
104 + }
105 +
106 + l := &listener{
107 + doneCh: listenerCtx.Done(),
108 + cancel: cancel,
109 + relayURL: relayurl,
110 + dialTimeout: dialTimeout,
111 + requestTimeout: requestTimeout,
112 + readyTarget: readyTarget,
113 + retryCount: cfg.RetryCount,
114 + retryWait: retryWait,
115 + leaseTTL: leaseTTL,
116 + renewBefore: renewBefore,
117 + identity: cfg.Identity.Copy(),
118 + metadata: cfg.Metadata.Copy(),
119 + banMITM: cfg.BanMITM,
120 + tcpEnabled: cfg.TCPEnabled,
121 + relaySet: cfg.relaySet,
122 }
123 l.mitmManager = newMITMManager(listenerCtx, l)
124 l.stream = transport.NewClientStream(readyTarget, handshakeTimeout)
@@ -108,36 +127,22 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
127 log.Info().
128 Err(err).
129 Str("component", "sdk-datagram-plane").
111 - Str("address", l.Address()).
130 + Str("address", l.address()).
131 Msg("quic datagram plane disconnected; waiting to reconnect")
132 })
133 }
134
116 - go l.runStartup(listenerCtx)
135 + go l.run(listenerCtx)
136 return l, nil
137 }
138
120 -func (l *Listener) runStartup(ctx context.Context) {
139 +func (l *listener) run(ctx context.Context) {
140 var retries int
141
142 for {
143 err := l.registerAndConfigure(ctx)
144 switch {
145 case err == nil:
127 - l.startStreamLoops(ctx)
128 - if l.datagram != nil {
129 - go l.runDatagramLoop(ctx)
130 - }
131 - go l.runRenewLoop(ctx)
132 - publicURL := l.PublicURL()
133 - event := log.Info().Str("address", l.Address())
134 - if publicURL != "" {
135 - event.
136 - Msg("service ready at " + publicURL)
137 - return
138 - }
139 - event.Msg("relay listener registered")
140 - return
146 case errors.Is(err, context.Canceled), errors.Is(err, net.ErrClosed):
147 return
148 default:
@@ -146,55 +151,75 @@ func (l *Listener) runStartup(ctx context.Context) {
151 errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeTransportMismatch}) ||
152 errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeHostnameConflict}) ||
153 errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeIPBanned}) {
149 - if l.relaySet != nil && l.api != nil && l.api.baseURL != nil {
150 - l.relaySet.UnconfirmRelayURL(l.api.baseURL.String())
151 - l.relaySet.RecordRelayFailure(l.api.baseURL.String(), err, 1)
154 + relayURL := l.relayURL.String()
155 + if l.relaySet != nil && relayURL != "" {
156 + l.relaySet.UnconfirmRelayURL(relayURL)
157 + l.relaySet.RecordRelayFailure(relayURL, err, 1)
158 }
159 log.Error().
160 Err(err).
155 - Str("relay_url", l.api.baseURL.String()).
156 - Str("address", l.Address()).
161 + Str("relay_url", relayURL).
162 + Str("address", l.address()).
163 Msg("lease registration failed; closing listener")
164 _ = l.Close()
165 return
166 }
167 retries++
162 - if !l.retryOrClose(ctx, "lease registration", err, retries) {
168 + if !l.waitRetry(ctx, "lease registration", err, retries) {
169 + _ = l.Close()
170 return
171 }
172 + continue
173 + }
174 +
175 + retries = 0
176 + publicURL := l.publicURL()
177 + event := log.Info().Str("address", l.address())
178 + if publicURL != "" {
179 + event.Msg("service ready at " + publicURL)
180 + } else {
181 + event.Msg("relay listener registered")
182 + }
183 +
184 + err = l.runLease(ctx)
185 + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) {
186 + return
187 + }
188 +
189 + if errors.Is(err, errLeaseRefreshRequired) {
190 + _, _, _, tenantTLSCloser := l.clearLease("lease refresh required")
191 + if tenantTLSCloser != nil {
192 + _ = tenantTLSCloser.Close()
193 + }
194 + l.resetTransport()
195 + continue
196 }
197 +
198 + relayURL := l.relayURL.String()
199 + log.Error().
200 + Err(err).
201 + Str("relay_url", relayURL).
202 + Str("address", l.address()).
203 + Msg("listener connection retry budget exhausted; closing listener")
204 + _ = l.Close()
205 + return
206 }
207 }
208
169 -func (l *Listener) Close() error {
209 +func (l *listener) Close() error {
210 var closeErr error
211 l.closeOnce.Do(func() {
212 if l.cancel != nil {
213 l.cancel()
214 }
215
216 + identity, registered, accessToken, tenantTLSCloser := l.clearLease("")
217 +
218 l.mu.Lock()
177 - identity := l.identity.Copy()
178 - registered := l.hostname != ""
179 - tlsCloser := l.tlsCloser
180 - streamCancel := l.streamCancel
219 stream := l.stream
220 datagram := l.datagram
183 - api := l.api
184 - l.hostname = ""
185 - l.udpAddr = ""
186 - l.tlsConfig = nil
187 - l.tlsCloser = nil
188 - l.streamCancel = nil
221 l.mu.Unlock()
222
191 - if l.mitmManager != nil {
192 - l.mitmManager.reset()
193 - }
194 - if streamCancel != nil {
195 - streamCancel()
196 - }
197 -
223 if stream != nil {
224 stream.Drain()
225 }
@@ -202,22 +227,45 @@ func (l *Listener) Close() error {
227 datagram.Close()
228 }
229
205 - if api != nil && registered && identity.Key() != "" {
230 + if registered && identity.Key() != "" && strings.TrimSpace(accessToken) != "" {
231 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
207 - closeErr = errors.Join(closeErr, api.unregisterLease(ctx))
232 + closeErr = errors.Join(closeErr, l.unregisterLease(ctx, accessToken))
233 cancel()
234 }
210 - if tlsCloser != nil {
211 - closeErr = errors.Join(closeErr, tlsCloser.Close())
212 - }
213 - if api != nil {
214 - api.close()
235 + if tenantTLSCloser != nil {
236 + closeErr = errors.Join(closeErr, tenantTLSCloser.Close())
237 }
238 + l.resetTransport()
239 })
240 return closeErr
241 }
242
220 -func (l *Listener) Accept() (net.Conn, error) {
243 +func (l *listener) clearLease(reason string) (types.Identity, bool, string, io.Closer) {
244 + l.mu.Lock()
245 + identity := l.identity.Copy()
246 + registered := l.hostname != ""
247 + accessToken := l.accessToken
248 + tenantTLSCloser := l.tenantTLSCloser
249 + datagram := l.datagram
250 + l.hostname = ""
251 + l.udpAddr = ""
252 + l.tenantTLSConfig = nil
253 + l.tenantTLSCloser = nil
254 + l.accessToken = ""
255 + l.expiresAt = time.Time{}
256 + l.sniPort = 0
257 + l.mu.Unlock()
258 +
259 + if l.mitmManager != nil {
260 + l.mitmManager.reset()
261 + }
262 + if datagram != nil && reason != "" {
263 + datagram.Clear(reason)
264 + }
265 + return identity, registered, accessToken, tenantTLSCloser
266 +}
267 +
268 +func (l *listener) Accept() (net.Conn, error) {
269 if l.stream == nil {
270 return nil, net.ErrClosed
271 }
@@ -231,8 +279,8 @@ func (l *Listener) Accept() (net.Conn, error) {
279 if handleErr != nil {
280 log.Debug().
281 Err(handleErr).
234 - Str("relay_url", l.api.baseURL.String()).
235 - Str("address", l.Address()).
282 + Str("relay_url", l.relayURL.String()).
283 + Str("address", l.address()).
284 Msg("mitm self-probe handling failed")
285 }
286 if handled {
@@ -242,7 +290,7 @@ func (l *Listener) Accept() (net.Conn, error) {
290 }
291 }
292
245 -func (l *Listener) AcceptDatagram() (types.DatagramFrame, error) {
293 +func (l *listener) acceptDatagram() (types.DatagramFrame, error) {
294 if l == nil || l.datagram == nil {
295 return types.DatagramFrame{}, net.ErrClosed
296 }
@@ -256,14 +304,14 @@ func (l *Listener) AcceptDatagram() (types.DatagramFrame, error) {
304 l.mu.Lock()
305 frame.Address = l.identity.Address
306 frame.UDPAddr = l.udpAddr
259 - if l.api != nil && l.api.baseURL != nil {
260 - frame.RelayURL = l.api.baseURL.String()
307 + if l.relayURL != nil {
308 + frame.RelayURL = l.relayURL.String()
309 }
310 l.mu.Unlock()
311 return frame, nil
312 }
313
266 -func (l *Listener) SendDatagram(frame types.DatagramFrame) error {
314 +func (l *listener) sendDatagram(frame types.DatagramFrame) error {
315 if l == nil || l.datagram == nil {
316 return net.ErrClosed
317 }
@@ -282,121 +330,104 @@ func (l *Listener) SendDatagram(frame types.DatagramFrame) error {
330 return datagram.Send(frame.FlowID, frame.Payload)
331 }
332
285 -func (l *Listener) DatagramReady() (string, bool, bool) {
333 +func (l *listener) datagramReady() (string, bool, bool) {
334 if l == nil || l.datagram == nil {
335 return "", false, false
336 }
337
338 l.mu.Lock()
339 + hostname := l.hostname
340 udpAddr := l.udpAddr
341 datagram := l.datagram
342 l.mu.Unlock()
343
344 ready := datagram != nil && datagram.Connected() && udpAddr != ""
296 - select {
297 - case <-l.registered:
298 - return udpAddr, ready, udpAddr != "" && !ready
299 - default:
300 - return udpAddr, ready, !l.closed()
301 - }
302 -}
303 -
304 -func (l *Listener) Addr() net.Addr {
305 - l.mu.Lock()
306 - defer l.mu.Unlock()
307 - if l.identity.Address == "" {
308 - return listenerAddr("portal:closed")
309 - }
310 - return listenerAddr("portal:" + l.identity.Address)
345 + pending := !ready && !l.closed() && (hostname == "" || udpAddr != "")
346 + return udpAddr, ready, pending
347 }
348
313 -func (l *Listener) Address() string {
349 +func (l *listener) address() string {
350 l.mu.Lock()
351 defer l.mu.Unlock()
352 return l.identity.Address
353 }
354
319 -func (l *Listener) Hostname() string {
320 - l.mu.Lock()
321 - defer l.mu.Unlock()
322 - return l.hostname
323 -}
324 -
325 -func (l *Listener) Metadata() types.LeaseMetadata {
326 - l.mu.Lock()
327 - defer l.mu.Unlock()
328 - return l.metadata.Copy()
329 -}
330 -
331 -func (l *Listener) Identity() types.Identity {
332 - if l == nil {
333 - return types.Identity{}
334 - }
335 - return l.identity.Copy()
336 -}
337 -
338 -func (l *Listener) PublicURL() string {
339 - if l == nil || l.api == nil || l.api.baseURL == nil {
355 +func (l *listener) publicURL() string {
356 + if l == nil || l.relayURL == nil {
357 return ""
358 }
359
360 l.mu.Lock()
361 hostname := l.hostname
362 l.mu.Unlock()
346 -
363 if hostname == "" {
364 return ""
365 }
366
351 - if l.api.baseURL.Scheme == "" {
367 + if l.relayURL.Scheme == "" {
368 return "https://" + hostname
369 }
370
371 host := hostname
356 - if port := l.api.baseURL.Port(); port != "" {
372 + if port := l.relayURL.Port(); port != "" {
373 host = net.JoinHostPort(hostname, port)
374 }
375
376 return (&url.URL{
361 - Scheme: l.api.baseURL.Scheme,
377 + Scheme: l.relayURL.Scheme,
378 Host: host,
379 }).String()
380 }
381
366 -func (l *Listener) startStreamLoops(parentCtx context.Context) {
367 - if l.stream == nil || l.readyTarget <= 0 {
368 - return
369 - }
370 -
371 - streamCtx, cancel := context.WithCancel(parentCtx)
372 -
382 +func (l *listener) runLease(ctx context.Context) error {
383 l.mu.Lock()
374 - if l.streamCancel != nil {
375 - l.streamCancel()
376 - }
377 - l.streamCancel = cancel
384 + identity := l.identity.Copy()
385 + accessToken := l.accessToken
386 + sniPort := l.sniPort
387 + tlsConfig := l.tenantTLSConfig
388 readyTarget := l.readyTarget
389 l.mu.Unlock()
390
381 - for range readyTarget {
382 - go l.runReverseSessionLoop(streamCtx)
383 - }
384 -}
391 + leaseCtx, cancel := context.WithCancel(ctx)
392 + defer cancel()
393
386 -func (l *Listener) stopStreamLoops() {
387 - l.mu.Lock()
388 - cancel := l.streamCancel
389 - l.streamCancel = nil
390 - l.mu.Unlock()
394 + errCh := make(chan error, max(readyTarget, 1)+1)
395 + if l.stream != nil && readyTarget > 0 {
396 + for range readyTarget {
397 + go func() {
398 + if err := l.runReverseSessionLoop(leaseCtx, accessToken, tlsConfig); err != nil {
399 + select {
400 + case errCh <- err:
401 + case <-leaseCtx.Done():
402 + }
403 + }
404 + }()
405 + }
406 + }
407 + if l.datagram != nil {
408 + go l.runDatagramLoop(leaseCtx, identity, accessToken, sniPort)
409 + }
410 + go func() {
411 + if err := l.runRenewLoop(leaseCtx); err != nil {
412 + select {
413 + case errCh <- err:
414 + case <-leaseCtx.Done():
415 + }
416 + }
417 + }()
418
392 - if cancel != nil {
419 + select {
420 + case <-ctx.Done():
421 + return ctx.Err()
422 + case err := <-errCh:
423 cancel()
424 + return err
425 }
426 }
427
397 -func (l *Listener) runReverseSessionLoop(ctx context.Context) {
428 +func (l *listener) runReverseSessionLoop(ctx context.Context, accessToken string, tlsConfig *tls.Config) error {
429 if l.stream == nil {
399 - return
430 + return nil
431 }
432
433 var retries int
@@ -404,31 +435,32 @@ func (l *Listener) runReverseSessionLoop(ctx context.Context) {
435 claimed, err := l.stream.RunSession(
436 ctx,
437 func(ctx context.Context) (net.Conn, error) {
407 - return l.api.openReverseSession(ctx)
438 + if strings.TrimSpace(accessToken) == "" {
439 + return nil, errors.New("access token is not available")
440 + }
441 + return l.openReverseSession(ctx, accessToken)
442 },
443 func() *tls.Config {
410 - l.mu.Lock()
411 - defer l.mu.Unlock()
412 - return l.tlsConfig
444 + return tlsConfig
445 },
446 )
447 switch {
448 case err == nil:
449 retries = 0
450 case errors.Is(err, context.Canceled), errors.Is(err, net.ErrClosed):
419 - return
451 + return nil
452 case claimed:
453 retries = 0
454 default:
455 retries++
424 - if !l.retryOrClose(ctx, "reverse session connect", err, retries) {
425 - return
456 + if !l.waitRetry(ctx, "reverse session connect", err, retries) {
457 + return err
458 }
459 }
460 }
461 }
462
431 -func (l *Listener) runDatagramLoop(ctx context.Context) {
463 +func (l *listener) runDatagramLoop(ctx context.Context, identity types.Identity, accessToken string, sniPort int) {
464 if l.datagram == nil {
465 return
466 }
@@ -436,35 +468,12 @@ func (l *Listener) runDatagramLoop(ctx context.Context) {
468 for {
469 select {
470 case <-ctx.Done():
439 - l.datagram.Close()
471 + l.datagram.Clear("lease stopped")
472 return
473 default:
474 }
475
444 - l.mu.Lock()
445 - api := l.api
446 - identity := l.identity.Copy()
447 - udpAddr := l.udpAddr
448 - l.mu.Unlock()
449 - if api == nil || identity.Key() == "" || udpAddr == "" {
450 - if !utils.SleepOrDone(ctx, time.Second) {
451 - l.datagram.Close()
452 - return
453 - }
454 - continue
455 - }
456 - api.mu.RLock()
457 - accessToken := api.accessToken
458 - api.mu.RUnlock()
459 - if strings.TrimSpace(accessToken) == "" {
460 - if !utils.SleepOrDone(ctx, time.Second) {
461 - l.datagram.Close()
462 - return
463 - }
464 - continue
465 - }
466 -
467 - conn, err := api.openQUICSession(ctx, accessToken)
476 + conn, err := l.openQUICSession(ctx, accessToken, sniPort)
477 if err != nil {
478 log.Info().
479 Err(err).
@@ -472,7 +481,7 @@ func (l *Listener) runDatagramLoop(ctx context.Context) {
481 Str("address", identity.Address).
482 Msg("quic datagram plane unavailable; retrying")
483 if !utils.SleepOrDone(ctx, 2*time.Second) {
475 - l.datagram.Close()
484 + l.datagram.Clear("lease stopped")
485 return
486 }
487 continue
@@ -502,7 +511,7 @@ func (l *Listener) runDatagramLoop(ctx context.Context) {
511
512 select {
513 case <-ctx.Done():
505 - l.datagram.Close()
514 + l.datagram.Clear("lease stopped")
515 return
516 case <-recvDone:
517 }
@@ -513,7 +522,7 @@ func (l *Listener) runDatagramLoop(ctx context.Context) {
522 }
523 }
524
516 -func (l *Listener) runRenewLoop(ctx context.Context) {
525 +func (l *listener) runRenewLoop(ctx context.Context) error {
526 interval := l.leaseTTL / 2
527 if interval <= 0 {
528 interval = 30 * time.Second
@@ -534,7 +543,7 @@ func (l *Listener) runRenewLoop(ctx context.Context) {
543 // duration would equal the timer interval, not real time.
544 before := time.Now().Round(0)
545 if !utils.SleepOrDone(ctx, interval) {
537 - return
546 + return ctx.Err()
547 }
548 elapsed := time.Since(before)
549
@@ -546,27 +555,9 @@ func (l *Listener) runRenewLoop(ctx context.Context) {
555 log.Info().
556 Dur("expected", interval).
557 Dur("actual", elapsed).
549 - Str("address", l.Address()).
558 + Str("address", l.address()).
559 Msg("system sleep/wake detected; resetting transport and re-registering")
551 -
552 - l.stopStreamLoops()
553 - l.api.resetTransport()
554 -
555 - var retries int
556 - for {
557 - if err := l.registerAndConfigure(ctx); err == nil {
558 - l.startStreamLoops(ctx)
559 - break
560 - } else if errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) {
561 - return
562 - } else {
563 - retries++
564 - if !l.retryOrClose(ctx, "post-wake re-registration", err, retries) {
565 - return
566 - }
567 - }
568 - }
569 - continue
560 + return errLeaseRefreshRequired
561 }
562
563 var retries int
@@ -576,57 +567,96 @@ func (l *Listener) runRenewLoop(ctx context.Context) {
567 break
568 }
569 if errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) {
579 - return
570 + return err
571 + }
572 + if errors.Is(err, errLeaseRefreshRequired) {
573 + return err
574 }
575
576 retries++
583 - if !l.retryOrClose(ctx, "lease renewal", err, retries) {
584 - return
577 + if !l.waitRetry(ctx, "lease renewal", err, retries) {
578 + return err
579 }
580 }
581 }
582 }
583
590 -func (l *Listener) renewLease(ctx context.Context) error {
591 - if time.Now().Before(l.api.expiresAt) {
592 - requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
593 - err := l.api.renewLease(requestCtx, l.leaseTTL)
594 - cancel()
595 - if err == nil {
596 - return nil
597 - }
598 - if !errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeLeaseNotFound}) {
599 - return err
600 - }
584 +func (l *listener) renewLease(ctx context.Context) error {
585 + l.mu.Lock()
586 + expiresAt := l.expiresAt
587 + accessToken := strings.TrimSpace(l.accessToken)
588 + l.mu.Unlock()
589 +
590 + if accessToken == "" || !time.Now().Before(expiresAt) {
591 + return errLeaseRefreshRequired
592 }
593
594 requestCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
604 - defer cancel()
605 - return l.registerAndConfigure(requestCtx)
595 + resp, err := l.renewRegisteredLease(requestCtx, l.leaseTTL, accessToken)
596 + cancel()
597 + if err != nil {
598 + if errors.Is(err, &types.APIRequestError{Code: types.APIErrorCodeLeaseNotFound}) {
599 + return errLeaseRefreshRequired
600 + }
601 + return err
602 + }
603 +
604 + resp.AccessToken = strings.TrimSpace(resp.AccessToken)
605 + if resp.AccessToken == "" {
606 + return errors.New("relay did not return renewed access token")
607 + }
608 + l.mu.Lock()
609 + if l.accessToken == accessToken {
610 + l.accessToken = resp.AccessToken
611 + l.expiresAt = resp.ExpiresAt
612 + }
613 + l.mu.Unlock()
614 + return nil
615 }
616
608 -func (l *Listener) registerAndConfigure(ctx context.Context) error {
609 - resp, err := l.api.registerLease(ctx, l.leaseTTL, l.datagram != nil, l.tcpEnabled)
617 +func (l *listener) registerAndConfigure(ctx context.Context) error {
618 + resp, err := l.registerLease(ctx, l.leaseTTL, l.datagram != nil, l.tcpEnabled)
619 if err != nil {
620 return err
621 }
622 + resp.AccessToken = strings.TrimSpace(resp.AccessToken)
623 + if resp.AccessToken == "" {
624 + return errors.New("relay did not return access token")
625 + }
626 + registeredIdentity, err := utils.NormalizeIdentity(resp.Identity)
627 + if err != nil {
628 + _ = l.unregisterLease(context.Background(), resp.AccessToken)
629 + return err
630 + }
631 + l.mu.Lock()
632 + localIdentity := l.identity.Copy()
633 + l.mu.Unlock()
634 + if registeredIdentity.Key() != localIdentity.Key() {
635 + _ = l.unregisterLease(context.Background(), resp.AccessToken)
636 + return errors.New("relay returned mismatched lease identity")
637 + }
638 + resp.Identity = registeredIdentity
639 if l.datagram != nil && !resp.UDPEnabled {
614 - _ = l.api.unregisterLease(context.Background())
640 + _ = l.unregisterLease(context.Background(), resp.AccessToken)
641 return &types.APIRequestError{
642 Code: types.APIErrorCodeFeatureUnavailable,
643 Message: "relay did not enable required udp support",
644 }
645 }
620 - tlsConf, tlsCloser, err := keyless.BuildClientTLSConfig(l.api.baseURL.String(), []string{resp.Hostname})
646 + if l.datagram != nil && resp.SNIPort <= 0 {
647 + _ = l.unregisterLease(context.Background(), resp.AccessToken)
648 + return errors.New("relay did not return sni port for udp transport")
649 + }
650 + tlsConf, tenantTLSCloser, err := keyless.BuildClientTLSConfig(l.relayURL.String(), []string{resp.Hostname})
651 if err != nil {
622 - _ = l.api.unregisterLease(context.Background())
652 + _ = l.unregisterLease(context.Background(), resp.AccessToken)
653 return err
654 }
655
656 if ctx.Err() != nil {
627 - _ = l.api.unregisterLease(context.Background())
628 - if tlsCloser != nil {
629 - _ = tlsCloser.Close()
657 + _ = l.unregisterLease(context.Background(), resp.AccessToken)
658 + if tenantTLSCloser != nil {
659 + _ = tenantTLSCloser.Close()
660 }
661 return ctx.Err()
662 }
@@ -634,20 +664,27 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
664 l.mu.Lock()
665 if ctx.Err() != nil {
666 l.mu.Unlock()
637 - _ = l.api.unregisterLease(context.Background())
638 - if tlsCloser != nil {
639 - _ = tlsCloser.Close()
667 + _ = l.unregisterLease(context.Background(), resp.AccessToken)
668 + if tenantTLSCloser != nil {
669 + _ = tenantTLSCloser.Close()
670 }
671 return ctx.Err()
672 }
643 - oldCloser := l.tlsCloser
673 + oldCloser := l.tenantTLSCloser
674 datagram := l.datagram
675 l.identity.Name = resp.Identity.Name
676 l.identity.Address = resp.Identity.Address
677 l.hostname = resp.Hostname
678 l.udpAddr = resp.UDPAddr
649 - l.tlsConfig = tlsConf
650 - l.tlsCloser = tlsCloser
679 + l.accessToken = resp.AccessToken
680 + l.expiresAt = resp.ExpiresAt
681 + if l.datagram != nil {
682 + l.sniPort = resp.SNIPort
683 + } else {
684 + l.sniPort = 0
685 + }
686 + l.tenantTLSConfig = tlsConf
687 + l.tenantTLSCloser = tenantTLSCloser
688 l.mu.Unlock()
689
690 if oldCloser != nil {
@@ -656,36 +693,39 @@ func (l *Listener) registerAndConfigure(ctx context.Context) error {
693 if datagram != nil {
694 datagram.Clear("lease updated")
695 }
659 - if l.relaySet != nil && l.api != nil && l.api.baseURL != nil {
660 - l.relaySet.ConfirmRelayURL(l.api.baseURL.String())
696 + relayURL := l.relayURL.String()
697 + if l.relaySet != nil && relayURL != "" {
698 + l.relaySet.ConfirmRelayURL(relayURL)
699 }
662 - l.registerOnce.Do(func() { close(l.registered) })
700 return nil
701 }
702
666 -func (l *Listener) retryOrClose(ctx context.Context, operation string, err error, retries int) bool {
703 +func (l *listener) waitRetry(ctx context.Context, operation string, err error, retries int) bool {
704 if ctx.Err() != nil {
705 return false
706 }
707
708 + relayURL := ""
709 + if l.relayURL != nil {
710 + relayURL = l.relayURL.String()
711 + }
712 logger := log.With().
672 - Str("relay_url", l.api.baseURL.String()).
713 + Str("relay_url", relayURL).
714 Str("operation", operation).
674 - Str("address", l.Address()).
715 + Str("address", l.address()).
716 Logger()
717
718 if l.retryCount > 0 && retries > l.retryCount {
678 - if l.relaySet != nil && l.api != nil && l.api.baseURL != nil {
679 - l.relaySet.UnconfirmRelayURL(l.api.baseURL.String())
680 - l.relaySet.RecordRelayFailure(l.api.baseURL.String(), err, 1)
719 + if l.relaySet != nil && relayURL != "" {
720 + l.relaySet.UnconfirmRelayURL(relayURL)
721 + l.relaySet.RecordRelayFailure(relayURL, err, 1)
722 }
723 if operation != "lease renewal" {
724 logger.Error().
725 Err(err).
726 Int("retry_count", l.retryCount).
686 - Msg("retry budget exhausted; closing listener")
727 + Msg("retry budget exhausted")
728 }
688 - _ = l.Close()
729 return false
730 }
731
@@ -698,23 +738,7 @@ func (l *Listener) retryOrClose(ctx context.Context, operation string, err error
738 Msg("operation failed; retrying")
739 }
740
701 - // Detect sleep/wake during the retry wait itself. If the OS
702 - // suspended the process, the renew loop will be re-registering
703 - // concurrently. Give it a moment to finish so the next attempt
704 - // uses a fresh access token and transport.
705 - // Round(0) forces wall-clock comparison (monotonic clock freezes
706 - // during macOS sleep).
707 - before := time.Now().Round(0)
708 - ok := utils.SleepOrDone(ctx, l.retryWait)
709 - if ok && time.Since(before) > l.retryWait+10*time.Second {
710 - logger.Info().
711 - Dur("expected", l.retryWait).
712 - Dur("actual", time.Since(before)).
713 - Msg("system sleep/wake detected during retry wait; pausing for re-registration")
714 - // Wait briefly for the renew loop to complete re-registration.
715 - utils.SleepOrDone(ctx, 3*time.Second)
716 - }
717 - return ok
741 + return utils.SleepOrDone(ctx, l.retryWait)
742 }
743
744 type listenerAddr string
@@ -722,7 +746,7 @@ type listenerAddr string
746 func (a listenerAddr) Network() string { return "portal" }
747 func (a listenerAddr) String() string { return string(a) }
748
725 -func (l *Listener) closed() bool {
749 +func (l *listener) closed() bool {
750 select {
751 case <-l.doneCh:
752 return true
@@ -731,17 +755,14 @@ func (l *Listener) closed() bool {
755 }
756 }
757
734 -func (l *Listener) ban() {
735 - if l.relaySet != nil && l.api != nil && l.api.baseURL != nil {
736 - l.relaySet.UnconfirmRelayURL(l.api.baseURL.String())
737 - l.relaySet.BanRelayURL(l.api.baseURL.String())
758 +func (l *listener) ban() {
759 + relayURL := ""
760 + if l.relayURL != nil {
761 + relayURL = l.relayURL.String()
762 }
739 - _ = l.Close()
740 -}
741 -
742 -func (l *Listener) BanMITM() bool {
743 - if l == nil {
744 - return false
763 + if l.relaySet != nil && relayURL != "" {
764 + l.relaySet.UnconfirmRelayURL(relayURL)
765 + l.relaySet.BanRelayURL(relayURL)
766 }
746 - return l.banMITM
767 + _ = l.Close()
768 }
sdk/mitm.go
+33 -22
@@ -51,7 +51,7 @@ type mitmProbeResult struct {
51
52 type mitmManager struct {
53 ctx context.Context
54 - listener *Listener
54 + listener *listener
55
56 mu sync.Mutex
57 pending map[string]*mitmProbePending
@@ -59,7 +59,7 @@ type mitmManager struct {
59 lastAt time.Time
60 }
61
62 -func newMITMManager(ctx context.Context, listener *Listener) *mitmManager {
62 +func newMITMManager(ctx context.Context, listener *listener) *mitmManager {
63 return &mitmManager{
64 ctx: ctx,
65 listener: listener,
@@ -77,24 +77,27 @@ func (m *mitmManager) reset() {
77
78 func (m *mitmManager) probeTLSPassthrough(ctx context.Context) (MITMProbeReport, error) {
79 l := m.listener
80 - if l == nil || l.api == nil || l.api.baseURL == nil {
80 + if l == nil || l.relayURL == nil {
81 return MITMProbeReport{}, errors.New("listener is not ready")
82 }
83
84 - publicURL := l.PublicURL()
84 + publicURL := l.publicURL()
85 if publicURL == "" {
86 return MITMProbeReport{}, errors.New("listener is not registered")
87 }
88
89 - hostname := l.Hostname()
89 + l.mu.Lock()
90 + hostname := l.hostname
91 + leaseTLSConfig := l.tenantTLSConfig
92 + l.mu.Unlock()
93 if hostname == "" {
94 return MITMProbeReport{}, errors.New("listener hostname is unavailable")
95 }
96
97 report := MITMProbeReport{
95 - RelayURL: l.api.baseURL.String(),
98 + RelayURL: l.relayURL.String(),
99 PublicURL: publicURL,
97 - Address: l.Address(),
100 + Address: l.address(),
101 }
102
103 probeCtx, cancel := context.WithTimeout(ctx, defaultMITMProbeTimeout)
@@ -115,18 +118,16 @@ func (m *mitmManager) probeTLSPassthrough(ctx context.Context) (MITMProbeReport,
118 ServerName: hostname,
119 InsecureSkipVerify: true,
120 }
118 - l.mu.Lock()
119 - if tlsConfig := l.tlsConfig; tlsConfig != nil {
120 - probeTLSConf.MinVersion = tlsConfig.MinVersion
121 - probeTLSConf.MaxVersion = tlsConfig.MaxVersion
122 - if len(tlsConfig.NextProtos) > 0 {
123 - probeTLSConf.NextProtos = append([]string(nil), tlsConfig.NextProtos...)
121 + if leaseTLSConfig != nil {
122 + probeTLSConf.MinVersion = leaseTLSConfig.MinVersion
123 + probeTLSConf.MaxVersion = leaseTLSConfig.MaxVersion
124 + if len(leaseTLSConfig.NextProtos) > 0 {
125 + probeTLSConf.NextProtos = append([]string(nil), leaseTLSConfig.NextProtos...)
126 }
127 }
126 - l.mu.Unlock()
128
129 dialer := &tls.Dialer{
129 - NetDialer: &net.Dialer{Timeout: l.api.dialTimeout},
130 + NetDialer: &net.Dialer{Timeout: l.dialTimeout},
131 Config: probeTLSConf,
132 }
133 conn, err := dialer.DialContext(probeCtx, "tcp", dialAddr)
@@ -180,15 +181,18 @@ func (m *mitmManager) probeTLSPassthrough(ctx context.Context) (MITMProbeReport,
181 }
182
183 func (m *mitmManager) probeDialAddress(publicURL string) (string, error) {
184 + l := m.listener
185 + if l == nil || l.relayURL == nil {
186 + return "", errors.New("listener is not ready")
187 + }
188 parsedURL, err := url.Parse(publicURL)
189 if err != nil {
190 return "", fmt.Errorf("parse public url: %w", err)
191 }
192
193 dialHost := parsedURL.Host
189 - l := m.listener
190 - if l != nil && l.api != nil && l.api.baseURL != nil && utils.IsLocalRelayHost(l.api.baseURL.Hostname()) {
191 - dialHost = l.api.baseURL.Host
194 + if utils.IsLocalRelayHost(l.relayURL.Hostname()) {
195 + dialHost = l.relayURL.Host
196 }
197 return utils.EnsurePort(dialHost), nil
198 }
@@ -222,6 +226,13 @@ func (m *mitmManager) maybeStart() {
226
227 func (m *mitmManager) logResult(report MITMProbeReport, err error) {
228 l := m.listener
229 + if l == nil {
230 + return
231 + }
232 + relayURL := ""
233 + if l.relayURL != nil {
234 + relayURL = l.relayURL.String()
235 + }
236 switch {
237 case l.closed():
238 return
@@ -231,8 +242,8 @@ func (m *mitmManager) logResult(report MITMProbeReport, err error) {
242 }
243 log.Warn().
244 Err(err).
234 - Str("relay_url", l.api.baseURL.String()).
235 - Str("address", l.Address()).
245 + Str("relay_url", relayURL).
246 + Str("address", l.address()).
247 Msg("tls passthrough self-probe failed")
248 case report.Reason == types.MITMProbeReasonProbeTimeout:
249 log.Warn().
@@ -242,12 +253,12 @@ func (m *mitmManager) logResult(report MITMProbeReport, err error) {
253 Msg("tls self-probe timed out before passthrough could be verified")
254 case report.Detected:
255 event := log.Warn().
245 - Bool("ban_mitm", l.BanMITM()).
256 + Bool("ban_mitm", l.banMITM).
257 Str("reason", report.Reason).
258 Str("relay_url", report.RelayURL).
259 Str("public_url", report.PublicURL).
260 Str("address", report.Address)
250 - if l.BanMITM() {
261 + if l.banMITM {
262 event.Msg("tls termination suspected by self-probe; banning relay")
263 l.ban()
264 return
sdk/mitm_test.go
+16 -18
@@ -26,7 +26,7 @@ func TestMITMProbeConnMatchesExporter(t *testing.T) {
26 defer closeMITMProbeTLSConn(clientConn)
27 defer closeMITMProbeTLSConn(serverConn)
28
29 - listener := &Listener{}
29 + listener := &listener{}
30 listener.mitmManager = newMITMManager(context.Background(), listener)
31
32 nonce := make([]byte, 16)
@@ -86,7 +86,7 @@ func TestMITMProbeConnDetectsExporterMismatch(t *testing.T) {
86 defer closeMITMProbeTLSConn(clientConn)
87 defer closeMITMProbeTLSConn(serverConn)
88
89 - listener := &Listener{}
89 + listener := &listener{}
90 listener.mitmManager = newMITMManager(context.Background(), listener)
91
92 nonce := make([]byte, 16)
@@ -144,7 +144,7 @@ func TestMITMProbeConnPassesThroughNormalTraffic(t *testing.T) {
144 defer closeMITMProbeTLSConn(clientConn)
145 defer closeMITMProbeTLSConn(serverConn)
146
147 - listener := &Listener{}
147 + listener := &listener{}
148 listener.mitmManager = newMITMManager(context.Background(), listener)
149
150 type handleResult struct {
@@ -206,8 +206,8 @@ func TestMITMProbeDetectionBansListener(t *testing.T) {
206 t.Fatalf("url.Parse() error = %v", err)
207 }
208
209 - listener := &Listener{
210 - api: &apiClient{baseURL: relayURL},
209 + listener := &listener{
210 + relayURL: relayURL,
211 relaySet: mustRelaySet(t, relayURL.String()),
212 cancel: func() {
213 select {
@@ -216,9 +216,8 @@ func TestMITMProbeDetectionBansListener(t *testing.T) {
216 close(doneCh)
217 }
218 },
219 - doneCh: doneCh,
220 - registered: make(chan struct{}),
221 - banMITM: true,
219 + doneCh: doneCh,
220 + banMITM: true,
221 }
222 listener.mitmManager = newMITMManager(context.Background(), listener)
223
@@ -245,12 +244,11 @@ func TestMITMProbeDetectionWarnsWithoutBanningListener(t *testing.T) {
244 t.Fatalf("url.Parse() error = %v", err)
245 }
246
248 - listener := &Listener{
249 - api: &apiClient{baseURL: relayURL},
250 - relaySet: mustRelaySet(t, relayURL.String()),
251 - doneCh: doneCh,
252 - registered: make(chan struct{}),
253 - banMITM: false,
247 + listener := &listener{
248 + relayURL: relayURL,
249 + relaySet: mustRelaySet(t, relayURL.String()),
250 + doneCh: doneCh,
251 + banMITM: false,
252 }
253 listener.mitmManager = newMITMManager(context.Background(), listener)
254
@@ -278,8 +276,8 @@ func TestMITMProbeDialAddressUsesRelayHostForLocalRelay(t *testing.T) {
276 t.Fatalf("url.Parse() error = %v", err)
277 }
278
281 - listener := &Listener{
282 - api: &apiClient{baseURL: relayURL},
279 + listener := &listener{
280 + relayURL: relayURL,
281 }
282 listener.mitmManager = newMITMManager(context.Background(), listener)
283
@@ -298,8 +296,8 @@ func TestMITMProbeDialAddressUsesPublicURLForRemoteRelay(t *testing.T) {
296 t.Fatalf("url.Parse() error = %v", err)
297 }
298
301 - listener := &Listener{
302 - api: &apiClient{baseURL: relayURL},
299 + listener := &listener{
300 + relayURL: relayURL,
301 }
302 listener.mitmManager = newMITMManager(context.Background(), listener)
303