sdk: add exposure helper for multi relay
Chang committed
Mar 8, 2026 at 11:17 UTC
0b104f1f837a4c7a548fa55454da4428be37579a
5 files changed
+902
-255
cmd/demo-app/main.go
+25
-33
@@ -17,22 +17,22 @@ import (
17
)
18
19
var (
20
- flagServerURL string
21
- flagAddr string
22
- flagName string
23
- flagDesc string
24
- flagTags string
25
- flagOwner string
26
- flagHide bool
27
- flagThumbnail string
20
+ flagServerURLs string
21
+ flagAddr string
22
+ flagName string
23
+ flagDesc string
24
+ flagTags string
25
+ flagOwner string
26
+ flagHide bool
27
+ flagThumbnail string
28
)
29
30
func main() {
31
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
32
logger := log.With().Str("component", "demo-app").Logger()
33
34
- flag.StringVar(&flagServerURL, "server-url", "https://localhost:4017", "relay API URL (https only)")
35
- flag.StringVar(&flagAddr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (disable if empty)")
34
+ flag.StringVar(&flagServerURLs, "server-urls", "https://localhost:4017", "relay API URLs (comma-separated; scheme omitted defaults to https)")
35
+ flag.StringVar(&flagAddr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (host:port or URL; disable if empty)")
36
flag.StringVar(&flagName, "name", "demo-app", "backend display name")
37
flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
38
flag.StringVar(&flagTags, "tags", "demo,connectivity,activity,cloud,sun,morning", "comma-separated lease tags")
@@ -53,34 +53,26 @@ func runDemo() error {
53
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
54
defer stop()
55
56
- sdkClient, err := sdk.NewClient(flagServerURL)
56
+ exposure, err := sdk.Expose(ctx, sdk.SplitCSV(flagServerURLs), flagName, types.LeaseMetadata{
57
+ Description: flagDesc,
58
+ Tags: sdk.SplitCSV(flagTags),
59
+ Owner: flagOwner,
60
+ Thumbnail: flagThumbnail,
61
+ Hide: flagHide,
62
+ })
63
if err != nil {
58
- return fmt.Errorf("new client: %w", err)
64
+ return fmt.Errorf("exposure listen error: %w", err)
65
+ }
66
+ defer exposure.Close()
67
+ if exposure == nil {
68
+ logger.Info().Msg("demo app running without relay")
69
}
60
- defer sdkClient.Close()
70
62
- listener, err := sdkClient.Listen(ctx, sdk.ListenRequest{
63
- Name: flagName,
64
- Metadata: types.LeaseMetadata{
65
- Description: flagDesc,
66
- Tags: sdk.SplitCSV(flagTags),
67
- Owner: flagOwner,
68
- Thumbnail: flagThumbnail,
69
- Hide: flagHide,
70
- },
71
- })
71
+ flagAddr, err := sdk.NormalizeTargetAddr(flagAddr)
72
if err != nil {
73
- return fmt.Errorf("listen: %w", err)
73
+ return fmt.Errorf("invalid --addr value %q: %w", flagAddr, err)
74
}
75
- defer listener.Close()
76
-
77
- logger.Info().
78
- Str("relay", flagServerURL).
79
- Str("lease_id", listener.LeaseID()).
80
- Strs("public_urls", listener.PublicURLs()).
81
- Str("local_addr", flagAddr).
82
- Msg("demo app registered with relay")
83
- if err := sdk.RunHTTP(ctx, listener, newHandler(), flagAddr); err != nil {
75
+ if err := exposure.RunHTTP(ctx, newHandler(), flagAddr); err != nil {
76
return err
77
}
78
cmd/portal-tunnel/main.go
+14
-41
@@ -5,7 +5,6 @@ import (
5
"errors"
6
"flag"
7
"fmt"
8
- "net"
8
"os"
9
"os/signal"
10
"sync"
@@ -41,7 +40,7 @@ func main() {
40
defaultRelayURLs = "https://localhost:4017"
41
}
42
44
- flag.StringVar(&flagRelayURLs, "relays", defaultRelayURLs, "Portal relay server API URLs (comma-separated, https only) [env: RELAYS]")
43
+ flag.StringVar(&flagRelayURLs, "relays", defaultRelayURLs, "Portal relay server API URLs (comma-separated; scheme omitted defaults to https) [env: RELAYS]")
44
flag.StringVar(&flagHost, "host", os.Getenv("APP_HOST"), "Target host to proxy to (host:port or URL) [env: APP_HOST]")
45
flag.StringVar(&flagName, "name", os.Getenv("APP_NAME"), "Service name [env: APP_NAME]")
46
flag.StringVar(&flagDesc, "description", os.Getenv("APP_DESCRIPTION"), "Service description metadata [env: APP_DESCRIPTION]")
@@ -63,64 +62,38 @@ func runTunnel() error {
62
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
63
defer stop()
64
66
- relayURLs, err := normalizeRelayURLs(flagRelayURLs)
65
+ exposure, err := sdk.Expose(ctx, sdk.SplitCSV(flagRelayURLs), flagName, types.LeaseMetadata{
66
+ Description: flagDesc,
67
+ Tags: sdk.SplitCSV(flagTags),
68
+ Owner: flagOwner,
69
+ Thumbnail: flagThumbnail,
70
+ Hide: flagHide,
71
+ })
72
if err != nil {
68
- return err
73
+ return fmt.Errorf("service %s: failed to start relays: %w", flagName, err)
74
}
70
- if len(relayURLs) == 0 {
75
+ if exposure == nil {
76
return errors.New("no relay URLs provided")
77
}
78
+ defer exposure.Close()
79
80
logger.Info().
81
Str("local", flagHost).
76
- Int("relay_count", len(relayURLs)).
77
- Strs("relays", relayURLs).
82
Msg("starting portal tunnel")
83
80
- listenReq := sdk.ListenRequest{
81
- Name: flagName,
82
- Metadata: types.LeaseMetadata{
83
- Description: flagDesc,
84
- Tags: sdk.SplitCSV(flagTags),
85
- Owner: flagOwner,
86
- Thumbnail: flagThumbnail,
87
- Hide: flagHide,
88
- },
89
- }
90
-
91
- runtimes, err := startRelayRuntimes(ctx, relayURLs, listenReq)
92
- if err != nil {
93
- return fmt.Errorf("service %s: failed to start relays: %w", flagName, err)
94
- }
95
-
84
var connWG sync.WaitGroup
85
var connCount atomic.Int64
98
- listeners := make([]net.Listener, 0, len(runtimes))
99
-
100
- for _, runtime := range runtimes {
101
- logger.Info().
102
- Str("relay", runtime.relayURL).
103
- Str("lease_id", runtime.listener.LeaseID()).
104
- Strs("public_urls", runtime.listener.PublicURLs()).
105
- Msg("relay tunnel ready")
106
- listeners = append(listeners, runtime.listener)
107
- }
86
109
- relayListener, err := sdk.MergeListeners(listeners...)
110
- if err != nil {
111
- closeErr := closeRelayRuntimes(runtimes)
112
- return errors.Join(fmt.Errorf("merge relay listeners: %w", err), closeErr)
113
- }
87
go func() {
88
<-ctx.Done()
116
- _ = relayListener.Close()
89
+ _ = exposure.Close()
90
}()
91
119
- waitErr := proxyRelayConnections(ctx, relayListener, flagHost, &connWG, &connCount)
92
+ waitErr := proxyRelayConnections(ctx, exposure, flagHost, &connWG, &connCount)
93
if waitErr != nil {
94
stop()
95
}
123
- closeErr := errors.Join(relayListener.Close(), closeRelayRuntimes(runtimes))
96
+ closeErr := exposure.Close()
97
if waitErr != nil {
98
logger.Error().Err(waitErr).Msg("relay supervisor exited with error")
99
}
cmd/portal-tunnel/relays.go
+1
-125
@@ -6,8 +6,6 @@ import (
6
"fmt"
7
"io"
8
"net"
9
- "net/url"
10
- "strings"
9
"sync"
10
"sync/atomic"
11
"time"
@@ -17,53 +15,6 @@ import (
15
"github.com/gosuda/portal/v2/sdk"
16
)
17
20
-type relayRuntime struct {
21
- client *sdk.Client
22
- listener *sdk.Listener
23
- relayURL string
24
-}
25
-
26
-func startRelayRuntimes(ctx context.Context, relayURLs []string, req sdk.ListenRequest) ([]*relayRuntime, error) {
27
- runtimes := make([]*relayRuntime, 0, len(relayURLs))
28
- for _, relayURL := range relayURLs {
29
- client, err := sdk.NewClient(relayURL)
30
- if err != nil {
31
- _ = closeRelayRuntimes(runtimes)
32
- return nil, fmt.Errorf("create relay client %s: %w", relayURL, err)
33
- }
34
-
35
- listener, err := client.Listen(ctx, req)
36
- if err != nil {
37
- client.Close()
38
- _ = closeRelayRuntimes(runtimes)
39
- return nil, fmt.Errorf("register relay lease %s: %w", relayURL, err)
40
- }
41
-
42
- runtimes = append(runtimes, &relayRuntime{
43
- relayURL: relayURL,
44
- client: client,
45
- listener: listener,
46
- })
47
- }
48
- return runtimes, nil
49
-}
50
-
51
-func closeRelayRuntimes(runtimes []*relayRuntime) error {
52
- var closeErr error
53
- for _, runtime := range runtimes {
54
- if runtime == nil {
55
- continue
56
- }
57
- if runtime.listener != nil {
58
- closeErr = errors.Join(closeErr, runtime.listener.Close())
59
- }
60
- if runtime.client != nil {
61
- runtime.client.Close()
62
- }
63
- }
64
- return closeErr
65
-}
66
-
18
func proxyRelayConnections(ctx context.Context, relayListener net.Listener, localAddr string, connWG *sync.WaitGroup, connCount *atomic.Int64) error {
19
logger := log.With().Str("component", "portal-tunnel").Logger()
20
@@ -97,40 +48,6 @@ func proxyRelayConnections(ctx context.Context, relayListener net.Listener, loca
48
}
49
}
50
100
-func normalizeRelayURLs(raw string) ([]string, error) {
101
- seen := make(map[string]struct{})
102
- var relayURLs []string
103
- for _, relayURL := range sdk.SplitCSV(raw) {
104
- normalized, err := normalizeRelayURL(relayURL)
105
- if err != nil {
106
- return nil, err
107
- }
108
- if _, ok := seen[normalized]; ok {
109
- continue
110
- }
111
- seen[normalized] = struct{}{}
112
- relayURLs = append(relayURLs, normalized)
113
- }
114
- return relayURLs, nil
115
-}
116
-
117
-func normalizeRelayURL(raw string) (string, error) {
118
- u, err := url.Parse(strings.TrimSpace(raw))
119
- if err != nil {
120
- return "", fmt.Errorf("parse relay url: %w", err)
121
- }
122
- if !strings.EqualFold(u.Scheme, "https") {
123
- return "", fmt.Errorf("relay url must use https: %q", raw)
124
- }
125
- if u.Host == "" {
126
- return "", fmt.Errorf("relay url host is empty: %q", raw)
127
- }
128
- u.Path = strings.TrimRight(u.Path, "/")
129
- u.RawQuery = ""
130
- u.Fragment = ""
131
- return u.String(), nil
132
-}
133
-
51
var bufferPool = sync.Pool{
52
New: func() any {
53
b := make([]byte, 64*1024)
@@ -141,7 +58,7 @@ var bufferPool = sync.Pool{
58
func proxyConnection(ctx context.Context, localAddr string, relayConn net.Conn) error {
59
defer relayConn.Close()
60
144
- targetAddr, err := normalizeTargetAddr(localAddr)
61
+ targetAddr, err := sdk.NormalizeTargetAddr(localAddr)
62
if err != nil {
63
return fmt.Errorf("invalid --host value %q: %w", localAddr, err)
64
}
@@ -215,44 +132,3 @@ func writeEmptyHTTPResponse(conn net.Conn) error {
132
_, err := conn.Write([]byte(response))
133
return err
134
}
218
-
219
-func normalizeTargetAddr(raw string) (string, error) {
220
- raw = strings.TrimSpace(raw)
221
- if raw == "" {
222
- return "", errors.New("target address is required")
223
- }
224
-
225
- if strings.Contains(raw, "://") {
226
- targetURL, err := url.Parse(raw)
227
- if err != nil {
228
- return "", fmt.Errorf("parse target url: %w", err)
229
- }
230
- if !strings.EqualFold(targetURL.Scheme, "http") && !strings.EqualFold(targetURL.Scheme, "https") {
231
- return "", fmt.Errorf("unsupported target url scheme %q", targetURL.Scheme)
232
- }
233
- if targetURL.Host == "" {
234
- return "", errors.New("target url host is empty")
235
- }
236
- if targetURL.Path != "" && targetURL.Path != "/" {
237
- return "", errors.New("target url path is not supported")
238
- }
239
- if targetURL.RawQuery != "" {
240
- return "", errors.New("target url query is not supported")
241
- }
242
- if targetURL.Fragment != "" {
243
- return "", errors.New("target url fragment is not supported")
244
- }
245
- raw = targetURL.Host
246
- }
247
-
248
- if _, _, err := net.SplitHostPort(raw); err == nil {
249
- return raw, nil
250
- }
251
- if strings.Count(raw, ":") == 0 {
252
- return net.JoinHostPort(raw, "80"), nil
253
- }
254
- if ip := net.ParseIP(raw); ip != nil {
255
- return net.JoinHostPort(raw, "80"), nil
256
- }
257
- return "", fmt.Errorf("invalid target address %q", raw)
258
-}
sdk/helper.go
+470
-41
@@ -6,73 +6,378 @@ import (
6
"fmt"
7
"net"
8
"net/http"
9
+ "net/url"
10
"strings"
11
"sync"
12
+ "sync/atomic"
13
12
- "golang.org/x/sync/errgroup"
14
+ "github.com/rs/zerolog/log"
15
+
16
+ "github.com/gosuda/portal/v2/types"
17
)
18
15
-// RunHTTP serves one handler on the relay listener and, when localAddr is set,
16
-// on the provided local HTTP address for app-local access.
19
+// Exposure owns the lifecycle of one or more relay listeners plus their
20
+// clients and accepts traffic from all of them through one net.Listener.
21
+type Exposure struct {
22
+ listener net.Listener
23
+ relays []exposureRelay
24
+
25
+ closeOnce sync.Once
26
+ connSeq atomic.Uint64
27
+}
28
+
29
+// Expose creates relay listeners for each normalized relay URL and exposes a
30
+// merged listener for accepting traffic from all of them. Empty relay input
31
+// returns nil, nil so callers can fall back to local-only serving.
32
+func Expose(ctx context.Context, relayUrls []string, name string, metadata types.LeaseMetadata) (*Exposure, error) {
33
+ relayURLs, err := NormalizeRelayURLs(relayUrls)
34
+ if err != nil {
35
+ return nil, err
36
+ }
37
+ if len(relayURLs) == 0 {
38
+ return nil, nil
39
+ }
40
+
41
+ relays := make([]exposureRelay, 0, len(relayURLs))
42
+ cleanup := func() error {
43
+ var closeErr error
44
+ for _, relay := range relays {
45
+ if relay.listener != nil {
46
+ closeErr = errors.Join(closeErr, relay.listener.Close())
47
+ }
48
+ if relay.client != nil {
49
+ relay.client.Close()
50
+ }
51
+ }
52
+ return closeErr
53
+ }
54
+
55
+ for _, relayURL := range relayURLs {
56
+ client, err := NewClient(relayURL)
57
+ if err != nil {
58
+ return nil, errors.Join(fmt.Errorf("new client %q: %w", relayURL, err), cleanup())
59
+ }
60
+
61
+ listener, err := client.Listen(ctx, ListenRequest{
62
+ Name: name,
63
+ Metadata: metadata,
64
+ })
65
+ if err != nil {
66
+ client.Close()
67
+ return nil, errors.Join(fmt.Errorf("listen %q: %w", relayURL, err), cleanup())
68
+ }
69
+
70
+ relays = append(relays, exposureRelay{
71
+ relayURL: relayURL,
72
+ publicURLs: append([]string(nil), listener.PublicURLs()...),
73
+ client: client,
74
+ listener: listener,
75
+ })
76
+ }
77
+
78
+ listeners := make([]net.Listener, 0, len(relays))
79
+ for _, relay := range relays {
80
+ listeners = append(listeners, relay.listener)
81
+ }
82
+
83
+ merged, err := mergeListeners(listeners...)
84
+ if err != nil {
85
+ return nil, errors.Join(fmt.Errorf("merge listeners: %w", err), cleanup())
86
+ }
87
+
88
+ exposure := &Exposure{
89
+ listener: merged,
90
+ relays: relays,
91
+ }
92
+
93
+ logger := log.With().Str("component", "sdk-exposure").Logger()
94
+ logger.Info().
95
+ Int("relay_count", len(exposure.relays)).
96
+ Strs("relays", exposure.RelayURLs()).
97
+ Strs("public_urls", exposure.PublicURLs()).
98
+ Msg("exposure ready")
99
+
100
+ return exposure, nil
101
+}
102
+
103
+// Accept implements net.Listener by accepting from the merged relay listener.
104
+func (e *Exposure) Accept() (net.Conn, error) {
105
+ if e == nil || e.listener == nil {
106
+ return nil, net.ErrClosed
107
+ }
108
+
109
+ conn, err := e.listener.Accept()
110
+ if err != nil {
111
+ if !errors.Is(err, net.ErrClosed) {
112
+ logger := log.With().Str("component", "sdk-exposure").Logger()
113
+ logger.Warn().
114
+ Err(err).
115
+ Str("local_addr", exposureAddrString(e.listener.Addr())).
116
+ Msg("exposure accept failed")
117
+ }
118
+ return nil, err
119
+ }
120
+
121
+ connID := e.connSeq.Add(1)
122
+ logger := log.With().Str("component", "sdk-exposure").Logger()
123
+ logger.Info().
124
+ Uint64("conn_id", connID).
125
+ Str("local_addr", exposureAddrString(conn.LocalAddr())).
126
+ Str("remote_addr", exposureAddrString(conn.RemoteAddr())).
127
+ Msg("exposure connection accepted")
128
+
129
+ return &exposureConn{
130
+ Conn: conn,
131
+ id: connID,
132
+ localAddr: exposureAddrString(conn.LocalAddr()),
133
+ remoteAddr: exposureAddrString(conn.RemoteAddr()),
134
+ }, nil
135
+}
136
+
137
+// Addr implements net.Listener.
138
+func (e *Exposure) Addr() net.Addr {
139
+ if e == nil || e.listener == nil {
140
+ return listenerAddr("portal:exposure")
141
+ }
142
+ return e.listener.Addr()
143
+}
144
+
145
+// RelayURLs returns the normalized relay URLs backing the exposure.
146
+func (e *Exposure) RelayURLs() []string {
147
+ if e == nil || len(e.relays) == 0 {
148
+ return nil
149
+ }
150
+
151
+ out := make([]string, 0, len(e.relays))
152
+ for _, relay := range e.relays {
153
+ out = append(out, relay.relayURL)
154
+ }
155
+ return out
156
+}
157
+
158
+// PublicURLs returns the de-duplicated public URLs exposed by the exposure.
159
+func (e *Exposure) PublicURLs() []string {
160
+ if e == nil || len(e.relays) == 0 {
161
+ return nil
162
+ }
163
+
164
+ out := make([]string, 0, len(e.relays))
165
+ seen := make(map[string]struct{})
166
+ for _, relay := range e.relays {
167
+ for _, rawURL := range relay.publicURLs {
168
+ if _, ok := seen[rawURL]; ok {
169
+ continue
170
+ }
171
+ seen[rawURL] = struct{}{}
172
+ out = append(out, rawURL)
173
+ }
174
+ }
175
+ if len(out) == 0 {
176
+ return nil
177
+ }
178
+ return out
179
+}
180
+
181
+// RunHTTP serves one handler on the exposure and, when localAddr is set, on
182
+// the provided local HTTP address for app-local access. Nil exposures support
183
+// local-only serving.
184
+func (e *Exposure) RunHTTP(ctx context.Context, handler http.Handler, localAddr string) error {
185
+ var relayListener net.Listener
186
+ if e != nil {
187
+ relayListener = e
188
+ }
189
+ return RunHTTP(ctx, relayListener, handler, localAddr)
190
+}
191
+
192
+// Close closes the merged listener and all underlying SDK clients.
193
+func (e *Exposure) Close() error {
194
+ if e == nil {
195
+ return nil
196
+ }
197
+
198
+ var closeErr error
199
+ e.closeOnce.Do(func() {
200
+ if e.listener != nil {
201
+ closeErr = errors.Join(closeErr, e.listener.Close())
202
+ }
203
+ for _, relay := range e.relays {
204
+ if relay.client != nil {
205
+ relay.client.Close()
206
+ }
207
+ }
208
+
209
+ logger := log.With().Str("component", "sdk-exposure").Logger()
210
+ event := logger.Info().
211
+ Int("relay_count", len(e.relays)).
212
+ Strs("relays", e.RelayURLs())
213
+ if closeErr != nil {
214
+ event = logger.Warn().
215
+ Err(closeErr).
216
+ Int("relay_count", len(e.relays)).
217
+ Strs("relays", e.RelayURLs())
218
+ }
219
+ event.Msg("exposure closed")
220
+ })
221
+ return closeErr
222
+}
223
+
224
+// RunHTTP serves one handler on relayListener and, when localAddr is set, on
225
+// the provided local HTTP address for app-local access.
226
func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handler, localAddr string) error {
18
- relaySrv := &http.Server{
19
- Handler: handler,
20
- ReadHeaderTimeout: defaultRequestTimeout,
227
+ localAddr = strings.TrimSpace(localAddr)
228
+ if ctx == nil {
229
+ ctx = context.Background()
230
+ }
231
+
232
+ if relayListener == nil && localAddr == "" {
233
+ return errors.New("relay listener or local address is required")
234
+ }
235
+
236
+ var relaySrv *http.Server
237
+ if relayListener != nil {
238
+ relaySrv = &http.Server{
239
+ Handler: handler,
240
+ ReadHeaderTimeout: defaultRequestTimeout,
241
+ }
242
}
243
244
var localSrv *http.Server
24
- if strings.TrimSpace(localAddr) != "" {
245
+ if localAddr != "" {
246
localSrv = &http.Server{
26
- Addr: strings.TrimSpace(localAddr),
247
+ Addr: localAddr,
248
Handler: handler,
249
ReadHeaderTimeout: defaultRequestTimeout,
250
}
251
}
252
32
- group, groupCtx := errgroup.WithContext(ctx)
253
+ serverCount := 0
254
+ if relaySrv != nil {
255
+ serverCount++
256
+ }
257
if localSrv != nil {
34
- group.Go(func() error {
35
- if err := localSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
36
- return fmt.Errorf("serve local http: %w", err)
37
- }
258
+ serverCount++
259
+ }
260
+
261
+ results := make(chan error, serverCount)
262
+ normalizeServeErr := func(err error, prefix string) error {
263
+ if err == nil || errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) {
264
return nil
265
+ }
266
+ return fmt.Errorf("%s: %w", prefix, err)
267
+ }
268
+
269
+ var (
270
+ shutdownOnce sync.Once
271
+ shutdownErr error
272
+ )
273
+ shutdown := func() error {
274
+ shutdownOnce.Do(func() {
275
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), defaultHTTPShutdownTimeout)
276
+ defer cancel()
277
+
278
+ var localErr error
279
+ if localSrv != nil {
280
+ localErr = localSrv.Shutdown(shutdownCtx)
281
+ if errors.Is(localErr, http.ErrServerClosed) {
282
+ localErr = nil
283
+ }
284
+ }
285
+
286
+ var relayErr error
287
+ if relaySrv != nil {
288
+ relayErr = relaySrv.Shutdown(shutdownCtx)
289
+ if errors.Is(relayErr, http.ErrServerClosed) {
290
+ relayErr = nil
291
+ }
292
+ }
293
+
294
+ shutdownErr = errors.Join(localErr, relayErr)
295
})
296
+ return shutdownErr
297
}
41
- group.Go(func() error {
42
- if err := relaySrv.Serve(relayListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
43
- return fmt.Errorf("serve relay http: %w", err)
44
- }
45
- return nil
46
- })
47
- group.Go(func() error {
48
- <-groupCtx.Done()
298
50
- shutdownCtx, cancel := context.WithTimeout(context.Background(), defaultHTTPShutdownTimeout)
51
- defer cancel()
299
+ if localSrv != nil {
300
+ go func() {
301
+ results <- normalizeServeErr(localSrv.ListenAndServe(), "serve local http")
302
+ }()
303
+ }
304
+ if relaySrv != nil {
305
+ go func() {
306
+ results <- normalizeServeErr(relaySrv.Serve(relayListener), "serve relay http")
307
+ }()
308
+ }
309
53
- var localErr error
54
- if localSrv != nil {
55
- localErr = localSrv.Shutdown(shutdownCtx)
56
- if errors.Is(localErr, http.ErrServerClosed) {
57
- localErr = nil
310
+ var serveErr error
311
+ remaining := serverCount
312
+ ctxDone := ctx.Done()
313
+ for remaining > 0 {
314
+ select {
315
+ case err := <-results:
316
+ remaining--
317
+ if err != nil {
318
+ serveErr = errors.Join(serveErr, err)
319
+ _ = shutdown()
320
}
321
+ case <-ctxDone:
322
+ _ = shutdown()
323
+ ctxDone = nil
324
}
325
+ }
326
+
327
+ return errors.Join(serveErr, shutdownErr)
328
+}
329
+
330
+type exposureRelay struct {
331
+ relayURL string
332
+ publicURLs []string
333
+ client *Client
334
+ listener *Listener
335
+}
336
+
337
+type exposureConn struct {
338
+ net.Conn
339
+ id uint64
340
+ localAddr string
341
+ remoteAddr string
342
+ closeOnce sync.Once
343
+}
344
61
- relayErr := relaySrv.Shutdown(shutdownCtx)
62
- if errors.Is(relayErr, http.ErrServerClosed) {
63
- relayErr = nil
345
+func (c *exposureConn) Close() error {
346
+ var closeErr error
347
+ c.closeOnce.Do(func() {
348
+ closeErr = c.Conn.Close()
349
+ if errors.Is(closeErr, net.ErrClosed) {
350
+ closeErr = nil
351
}
352
66
- return errors.Join(localErr, relayErr)
353
+ logger := log.With().Str("component", "sdk-exposure").Logger()
354
+ event := logger.Info().
355
+ Uint64("conn_id", c.id).
356
+ Str("local_addr", c.localAddr).
357
+ Str("remote_addr", c.remoteAddr)
358
+ if closeErr != nil {
359
+ event = logger.Warn().
360
+ Err(closeErr).
361
+ Uint64("conn_id", c.id).
362
+ Str("local_addr", c.localAddr).
363
+ Str("remote_addr", c.remoteAddr)
364
+ }
365
+ event.Msg("exposure connection closed")
366
})
367
+ return closeErr
368
+}
369
69
- return group.Wait()
370
+func exposureAddrString(addr net.Addr) string {
371
+ if addr == nil {
372
+ return ""
373
+ }
374
+ return addr.String()
375
}
376
72
-// MergeListeners fans in multiple listeners into one net.Listener. It keeps
377
+// mergeListeners fans in multiple listeners into one net.Listener. It keeps
378
// serving accepts from remaining listeners when one listener stops, and returns
379
// a terminal error only after all source listeners have stopped.
75
-func MergeListeners(listeners ...net.Listener) (net.Listener, error) {
380
+func mergeListeners(listeners ...net.Listener) (net.Listener, error) {
381
if len(listeners) == 0 {
382
return nil, errors.New("at least one listener is required")
383
}
@@ -113,15 +418,16 @@ type mergedListener struct {
418
func (l *mergedListener) Accept() (net.Conn, error) {
419
conn, ok := <-l.accepted
420
if ok {
421
+ select {
422
+ case <-l.closed:
423
+ _ = conn.Close()
424
+ return nil, l.terminalErrorOr(net.ErrClosed)
425
+ default:
426
+ }
427
return conn, nil
428
}
429
119
- l.mu.Lock()
120
- defer l.mu.Unlock()
121
- if l.terminalErr == nil {
122
- return nil, net.ErrClosed
123
- }
124
- return nil, l.terminalErr
430
+ return nil, l.terminalErrorOr(net.ErrClosed)
431
}
432
433
func (l *mergedListener) Close() error {
@@ -167,6 +473,14 @@ func (l *mergedListener) runAcceptLoop(listener net.Listener) {
473
return
474
}
475
476
+ select {
477
+ case <-l.closed:
478
+ _ = conn.Close()
479
+ l.finishWorker()
480
+ return
481
+ default:
482
+ }
483
+
484
select {
485
case l.accepted <- conn:
486
case <-l.closed:
@@ -201,6 +515,15 @@ func (l *mergedListener) recordTerminalError(err error) {
515
l.mu.Unlock()
516
}
517
518
+func (l *mergedListener) terminalErrorOr(fallback error) error {
519
+ l.mu.Lock()
520
+ defer l.mu.Unlock()
521
+ if l.terminalErr == nil {
522
+ return fallback
523
+ }
524
+ return l.terminalErr
525
+}
526
+
527
// SplitCSV splits a comma-separated string, trimming whitespace and dropping
528
// empty entries.
529
func SplitCSV(raw string) []string {
@@ -218,3 +541,109 @@ func SplitCSV(raw string) []string {
541
}
542
return out
543
}
544
+
545
+// NormalizeRelayURLs splits, normalizes, and de-duplicates relay URLs while
546
+// preserving input order. Empty inputs return nil, nil.
547
+func NormalizeRelayURLs(inputs []string) ([]string, error) {
548
+ out := make([]string, 0, len(inputs))
549
+ seen := make(map[string]struct{}, len(inputs))
550
+
551
+ for _, input := range inputs {
552
+ for _, part := range SplitCSV(input) {
553
+ normalized, err := NormalizeRelayURL(part)
554
+ if err != nil {
555
+ return nil, err
556
+ }
557
+ if _, ok := seen[normalized]; ok {
558
+ continue
559
+ }
560
+ seen[normalized] = struct{}{}
561
+ out = append(out, normalized)
562
+ }
563
+ }
564
+
565
+ if len(out) == 0 {
566
+ return nil, nil
567
+ }
568
+ return out, nil
569
+}
570
+
571
+// NormalizeRelayURL accepts host[:port] or https URLs and returns the canonical
572
+// relay base URL used by the SDK.
573
+func NormalizeRelayURL(raw string) (string, error) {
574
+ trimmed := strings.TrimSpace(raw)
575
+ if trimmed == "" {
576
+ return "", errors.New("relay url is empty")
577
+ }
578
+ if !strings.Contains(trimmed, "://") {
579
+ trimmed = "https://" + strings.TrimPrefix(trimmed, "//")
580
+ }
581
+
582
+ parsed, err := url.Parse(trimmed)
583
+ if err != nil {
584
+ return "", fmt.Errorf("parse relay url %q: %w", raw, err)
585
+ }
586
+ if parsed.Host == "" && parsed.Path != "" && !strings.Contains(parsed.Path, "/") {
587
+ parsed, err = url.Parse("https://" + strings.TrimSpace(parsed.Path))
588
+ if err != nil {
589
+ return "", fmt.Errorf("parse relay url %q: %w", raw, err)
590
+ }
591
+ }
592
+ if parsed.Host == "" {
593
+ return "", fmt.Errorf("relay url host is empty: %q", raw)
594
+ }
595
+ if !strings.EqualFold(parsed.Scheme, "https") {
596
+ return "", fmt.Errorf("relay url must use https: %q", raw)
597
+ }
598
+
599
+ parsed.RawQuery = ""
600
+ parsed.Fragment = ""
601
+ parsed.Path = strings.TrimRight(parsed.Path, "/")
602
+ if strings.HasSuffix(strings.ToLower(parsed.Path), "/relay") {
603
+ parsed.Path = strings.TrimSuffix(parsed.Path, "/relay")
604
+ }
605
+ return parsed.String(), nil
606
+}
607
+
608
+// NormalizeTargetAddr accepts host[:port] or http/https URLs and returns a
609
+// canonical host:port target address for local dialing.
610
+func NormalizeTargetAddr(raw string) (string, error) {
611
+ raw = strings.TrimSpace(raw)
612
+ if raw == "" {
613
+ return "", errors.New("target address is required")
614
+ }
615
+
616
+ if strings.Contains(raw, "://") {
617
+ targetURL, err := url.Parse(raw)
618
+ if err != nil {
619
+ return "", fmt.Errorf("parse target url: %w", err)
620
+ }
621
+ if !strings.EqualFold(targetURL.Scheme, "http") && !strings.EqualFold(targetURL.Scheme, "https") {
622
+ return "", fmt.Errorf("unsupported target url scheme %q", targetURL.Scheme)
623
+ }
624
+ if targetURL.Host == "" {
625
+ return "", errors.New("target url host is empty")
626
+ }
627
+ if targetURL.Path != "" && targetURL.Path != "/" {
628
+ return "", errors.New("target url path is not supported")
629
+ }
630
+ if targetURL.RawQuery != "" {
631
+ return "", errors.New("target url query is not supported")
632
+ }
633
+ if targetURL.Fragment != "" {
634
+ return "", errors.New("target url fragment is not supported")
635
+ }
636
+ raw = targetURL.Host
637
+ }
638
+
639
+ if _, _, err := net.SplitHostPort(raw); err == nil {
640
+ return raw, nil
641
+ }
642
+ if strings.Count(raw, ":") == 0 {
643
+ return net.JoinHostPort(raw, "80"), nil
644
+ }
645
+ if ip := net.ParseIP(raw); ip != nil {
646
+ return net.JoinHostPort(raw, "80"), nil
647
+ }
648
+ return "", fmt.Errorf("invalid target address %q", raw)
649
+}
sdk/helper_test.go
+392
-15
@@ -6,12 +6,15 @@ import (
6
"io"
7
"net"
8
"net/http"
9
+ "reflect"
10
"sort"
11
"testing"
12
"time"
13
+
14
+ "github.com/gosuda/portal/v2/types"
15
)
16
14
-func TestRunHTTPAppRelayOnly(t *testing.T) {
17
+func TestRunHTTPRelayOnly(t *testing.T) {
18
t.Parallel()
19
20
listener, err := net.Listen("tcp", "127.0.0.1:0")
@@ -36,14 +39,57 @@ func TestRunHTTPAppRelayOnly(t *testing.T) {
39
select {
40
case err := <-errCh:
41
if err != nil {
39
- t.Fatalf("RunHTTPApp() error = %v", err)
42
+ t.Fatalf("RunHTTP() error = %v", err)
43
}
44
case <-time.After(3 * time.Second):
42
- t.Fatal("RunHTTPApp() did not exit after context cancellation")
45
+ t.Fatal("RunHTTP() did not exit after context cancellation")
46
}
47
}
48
46
-func TestRunHTTPAppLocalAndRelay(t *testing.T) {
49
+func TestExposureRunHTTPLocalOnly(t *testing.T) {
50
+ t.Parallel()
51
+
52
+ localListener, err := net.Listen("tcp", "127.0.0.1:0")
53
+ if err != nil {
54
+ t.Fatalf("Listen() error = %v", err)
55
+ }
56
+ localAddr := localListener.Addr().String()
57
+ _ = localListener.Close()
58
+
59
+ ctx, cancel := context.WithCancel(context.Background())
60
+ defer cancel()
61
+
62
+ var exposure *Exposure
63
+ errCh := make(chan error, 1)
64
+ go func() {
65
+ errCh <- exposure.RunHTTP(ctx, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
66
+ _, _ = io.WriteString(w, "ok")
67
+ }), localAddr)
68
+ }()
69
+
70
+ waitForHTTP(t, "http://"+localAddr)
71
+ cancel()
72
+
73
+ select {
74
+ case err := <-errCh:
75
+ if err != nil {
76
+ t.Fatalf("RunHTTP() error = %v", err)
77
+ }
78
+ case <-time.After(3 * time.Second):
79
+ t.Fatal("RunHTTP() did not exit after context cancellation")
80
+ }
81
+}
82
+
83
+func TestRunHTTPRequiresRelayOrLocal(t *testing.T) {
84
+ t.Parallel()
85
+
86
+ err := RunHTTP(context.Background(), nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), "")
87
+ if err == nil {
88
+ t.Fatal("RunHTTP() error = nil, want error")
89
+ }
90
+}
91
+
92
+func TestRunHTTPLocalAndRelay(t *testing.T) {
93
t.Parallel()
94
95
relayListener, err := net.Listen("tcp", "127.0.0.1:0")
@@ -76,10 +122,96 @@ func TestRunHTTPAppLocalAndRelay(t *testing.T) {
122
select {
123
case err := <-errCh:
124
if err != nil {
79
- t.Fatalf("RunHTTPApp() error = %v", err)
125
+ t.Fatalf("RunHTTP() error = %v", err)
126
+ }
127
+ case <-time.After(3 * time.Second):
128
+ t.Fatal("RunHTTP() did not exit after context cancellation")
129
+ }
130
+}
131
+
132
+func TestRunHTTPRelayListenerCloseIsNormal(t *testing.T) {
133
+ t.Parallel()
134
+
135
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
136
+ if err != nil {
137
+ t.Fatalf("Listen() error = %v", err)
138
+ }
139
+
140
+ ctx, cancel := context.WithCancel(context.Background())
141
+ defer cancel()
142
+
143
+ errCh := make(chan error, 1)
144
+ go func() {
145
+ errCh <- RunHTTP(ctx, listener, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
146
+ _, _ = io.WriteString(w, "ok")
147
+ }), "")
148
+ }()
149
+
150
+ waitForHTTP(t, "http://"+listener.Addr().String())
151
+
152
+ if err := listener.Close(); err != nil {
153
+ t.Fatalf("listener.Close() error = %v", err)
154
+ }
155
+
156
+ select {
157
+ case err := <-errCh:
158
+ if err != nil {
159
+ t.Fatalf("RunHTTP() error = %v", err)
160
}
161
case <-time.After(3 * time.Second):
82
- t.Fatal("RunHTTPApp() did not exit after context cancellation")
162
+ t.Fatal("RunHTTP() did not exit after listener close")
163
+ }
164
+}
165
+
166
+func TestRunHTTPRelayListenerCloseKeepsLocalRunning(t *testing.T) {
167
+ t.Parallel()
168
+
169
+ relayListener, err := net.Listen("tcp", "127.0.0.1:0")
170
+ if err != nil {
171
+ t.Fatalf("Listen() error = %v", err)
172
+ }
173
+
174
+ localListener, err := net.Listen("tcp", "127.0.0.1:0")
175
+ if err != nil {
176
+ t.Fatalf("Listen() error = %v", err)
177
+ }
178
+ localAddr := localListener.Addr().String()
179
+ _ = localListener.Close()
180
+
181
+ ctx, cancel := context.WithCancel(context.Background())
182
+ defer cancel()
183
+
184
+ errCh := make(chan error, 1)
185
+ go func() {
186
+ errCh <- RunHTTP(ctx, relayListener, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
187
+ _, _ = io.WriteString(w, "ok")
188
+ }), localAddr)
189
+ }()
190
+
191
+ waitForHTTP(t, "http://"+relayListener.Addr().String())
192
+ waitForHTTP(t, "http://"+localAddr)
193
+
194
+ if err := relayListener.Close(); err != nil {
195
+ t.Fatalf("relayListener.Close() error = %v", err)
196
+ }
197
+
198
+ waitForHTTP(t, "http://"+localAddr)
199
+
200
+ select {
201
+ case err := <-errCh:
202
+ t.Fatalf("RunHTTP() exited early with %v", err)
203
+ case <-time.After(200 * time.Millisecond):
204
+ }
205
+
206
+ cancel()
207
+
208
+ select {
209
+ case err := <-errCh:
210
+ if err != nil {
211
+ t.Fatalf("RunHTTP() error = %v", err)
212
+ }
213
+ case <-time.After(3 * time.Second):
214
+ t.Fatal("RunHTTP() did not exit after context cancellation")
215
}
216
}
217
@@ -105,12 +237,12 @@ func waitForHTTP(t *testing.T, rawURL string) {
237
func TestMergeListenersRequiresInput(t *testing.T) {
238
t.Parallel()
239
108
- listener, err := MergeListeners()
240
+ listener, err := mergeListeners()
241
if err == nil {
110
- t.Fatal("MergeListeners() error = nil, want error")
242
+ t.Fatal("mergeListeners() error = nil, want error")
243
}
244
if listener != nil {
113
- t.Fatalf("MergeListeners() listener = %#v, want nil", listener)
245
+ t.Fatalf("mergeListeners() listener = %#v, want nil", listener)
246
}
247
}
248
@@ -129,9 +261,9 @@ func TestMergeListenersAcceptsFromAllSources(t *testing.T) {
261
}
262
defer listener2.Close()
263
132
- merged, err := MergeListeners(listener1, listener2)
264
+ merged, err := mergeListeners(listener1, listener2)
265
if err != nil {
134
- t.Fatalf("MergeListeners() error = %v", err)
266
+ t.Fatalf("mergeListeners() error = %v", err)
267
}
268
defer merged.Close()
269
@@ -180,9 +312,9 @@ func TestMergeListenersContinuesAfterOneSourceCloses(t *testing.T) {
312
}
313
defer listener2.Close()
314
183
- merged, err := MergeListeners(listener1, listener2)
315
+ merged, err := mergeListeners(listener1, listener2)
316
if err != nil {
185
- t.Fatalf("MergeListeners() error = %v", err)
317
+ t.Fatalf("mergeListeners() error = %v", err)
318
}
319
defer merged.Close()
320
@@ -229,9 +361,9 @@ func TestMergeListenersCloseUnblocksAccept(t *testing.T) {
361
}
362
defer listener.Close()
363
232
- merged, err := MergeListeners(listener)
364
+ merged, err := mergeListeners(listener)
365
if err != nil {
234
- t.Fatalf("MergeListeners() error = %v", err)
366
+ t.Fatalf("mergeListeners() error = %v", err)
367
}
368
369
errCh := make(chan error, 1)
@@ -253,3 +385,248 @@ func TestMergeListenersCloseUnblocksAccept(t *testing.T) {
385
t.Fatal("Accept() did not unblock after Close()")
386
}
387
}
388
+
389
+func TestMergedListenerAcceptDoesNotReturnQueuedConnAfterClose(t *testing.T) {
390
+ t.Parallel()
391
+
392
+ conn := &stubConn{}
393
+ closed := make(chan struct{})
394
+ close(closed)
395
+
396
+ merged := &mergedListener{
397
+ accepted: make(chan net.Conn, 1),
398
+ closed: closed,
399
+ }
400
+ merged.accepted <- conn
401
+
402
+ gotConn, err := merged.Accept()
403
+ if gotConn != nil {
404
+ t.Fatalf("Accept() conn = %#v, want nil", gotConn)
405
+ }
406
+ if !errors.Is(err, net.ErrClosed) {
407
+ t.Fatalf("Accept() error = %v, want net.ErrClosed", err)
408
+ }
409
+ if conn.closeCount != 1 {
410
+ t.Fatalf("conn close count = %d, want 1", conn.closeCount)
411
+ }
412
+}
413
+
414
+func TestNormalizeRelayURLs(t *testing.T) {
415
+ t.Parallel()
416
+
417
+ got, err := NormalizeRelayURLs([]string{
418
+ " localhost:4017 , https://relay.example.com/base/relay?x=1#frag ",
419
+ "https://relay.example.com/base",
420
+ })
421
+ if err != nil {
422
+ t.Fatalf("NormalizeRelayURLs() error = %v", err)
423
+ }
424
+
425
+ want := []string{
426
+ "https://localhost:4017",
427
+ "https://relay.example.com/base",
428
+ }
429
+ if !reflect.DeepEqual(got, want) {
430
+ t.Fatalf("NormalizeRelayURLs() = %v, want %v", got, want)
431
+ }
432
+}
433
+
434
+func TestNormalizeRelayURLRejectsNonHTTPS(t *testing.T) {
435
+ t.Parallel()
436
+
437
+ _, err := NormalizeRelayURL("http://relay.example.com")
438
+ if err == nil {
439
+ t.Fatal("NormalizeRelayURL() error = nil, want error")
440
+ }
441
+}
442
+
443
+func TestNormalizeTargetAddr(t *testing.T) {
444
+ t.Parallel()
445
+
446
+ tests := []struct {
447
+ name string
448
+ input string
449
+ want string
450
+ }{
451
+ {
452
+ name: "host only",
453
+ input: "localhost",
454
+ want: "localhost:80",
455
+ },
456
+ {
457
+ name: "host and port",
458
+ input: "127.0.0.1:8080",
459
+ want: "127.0.0.1:8080",
460
+ },
461
+ {
462
+ name: "http url",
463
+ input: "http://localhost:3000",
464
+ want: "localhost:3000",
465
+ },
466
+ {
467
+ name: "https url default port preserved by host parsing",
468
+ input: "https://example.com",
469
+ want: "example.com:80",
470
+ },
471
+ {
472
+ name: "ipv6 host",
473
+ input: "::1",
474
+ want: "[::1]:80",
475
+ },
476
+ }
477
+
478
+ for _, tt := range tests {
479
+ t.Run(tt.name, func(t *testing.T) {
480
+ t.Parallel()
481
+
482
+ got, err := NormalizeTargetAddr(tt.input)
483
+ if err != nil {
484
+ t.Fatalf("NormalizeTargetAddr() error = %v", err)
485
+ }
486
+ if got != tt.want {
487
+ t.Fatalf("NormalizeTargetAddr() = %q, want %q", got, tt.want)
488
+ }
489
+ })
490
+ }
491
+}
492
+
493
+func TestNormalizeTargetAddrRejectsInvalidInput(t *testing.T) {
494
+ t.Parallel()
495
+
496
+ inputs := []string{
497
+ "",
498
+ "ftp://example.com",
499
+ "http://example.com/path",
500
+ "http://example.com?a=1",
501
+ "http://example.com#frag",
502
+ "host:port:extra",
503
+ }
504
+
505
+ for _, input := range inputs {
506
+ t.Run(input, func(t *testing.T) {
507
+ t.Parallel()
508
+
509
+ if _, err := NormalizeTargetAddr(input); err == nil {
510
+ t.Fatalf("NormalizeTargetAddr(%q) error = nil, want error", input)
511
+ }
512
+ })
513
+ }
514
+}
515
+
516
+func TestExposeNoRelayInputs(t *testing.T) {
517
+ t.Parallel()
518
+
519
+ exposure, err := Expose(context.Background(), nil, "demo", types.LeaseMetadata{})
520
+ if err != nil {
521
+ t.Fatalf("Expose() error = %v", err)
522
+ }
523
+ if exposure != nil {
524
+ t.Fatalf("Expose() exposure = %#v, want nil", exposure)
525
+ }
526
+}
527
+
528
+func TestExposureAccessorsReturnCopies(t *testing.T) {
529
+ t.Parallel()
530
+
531
+ exposure := &Exposure{
532
+ relays: []exposureRelay{
533
+ {
534
+ relayURL: "https://relay-1.example.com",
535
+ publicURLs: []string{"https://app.example.com"},
536
+ },
537
+ {
538
+ relayURL: "https://relay-2.example.com",
539
+ publicURLs: []string{"https://app.example.com"},
540
+ },
541
+ },
542
+ }
543
+
544
+ relayURLs := exposure.RelayURLs()
545
+ publicURLs := exposure.PublicURLs()
546
+
547
+ relayURLs[0] = "changed"
548
+ publicURLs[0] = "changed"
549
+
550
+ if got, want := exposure.RelayURLs(), []string{"https://relay-1.example.com", "https://relay-2.example.com"}; !reflect.DeepEqual(got, want) {
551
+ t.Fatalf("RelayURLs() = %v, want %v", got, want)
552
+ }
553
+ if got, want := exposure.PublicURLs(), []string{"https://app.example.com"}; !reflect.DeepEqual(got, want) {
554
+ t.Fatalf("PublicURLs() = %v, want %v", got, want)
555
+ }
556
+ if got, want := exposure.relays[0].relayURL, "https://relay-1.example.com"; got != want {
557
+ t.Fatalf("relays[0].relayURL = %q, want %q", got, want)
558
+ }
559
+ if got, want := exposure.relays[0].publicURLs, []string{"https://app.example.com"}; !reflect.DeepEqual(got, want) {
560
+ t.Fatalf("relays[0].publicURLs = %v, want %v", got, want)
561
+ }
562
+}
563
+
564
+func TestExposureCloseIsIdempotent(t *testing.T) {
565
+ t.Parallel()
566
+
567
+ listener := &stubListener{}
568
+ exposure := &Exposure{listener: listener}
569
+
570
+ if err := exposure.Close(); err != nil {
571
+ t.Fatalf("Close() error = %v", err)
572
+ }
573
+ if err := exposure.Close(); err != nil {
574
+ t.Fatalf("second Close() error = %v", err)
575
+ }
576
+ if listener.closeCount != 1 {
577
+ t.Fatalf("listener close count = %d, want 1", listener.closeCount)
578
+ }
579
+}
580
+
581
+func TestExposureImplementsListener(t *testing.T) {
582
+ t.Parallel()
583
+
584
+ listener := &stubListener{addr: listenerAddr("merged:test")}
585
+ exposure := &Exposure{listener: listener}
586
+
587
+ if got := exposure.Addr().String(); got != "merged:test" {
588
+ t.Fatalf("Addr().String() = %q, want %q", got, "merged:test")
589
+ }
590
+
591
+ _, err := exposure.Accept()
592
+ if !errors.Is(err, net.ErrClosed) {
593
+ t.Fatalf("Accept() error = %v, want net.ErrClosed", err)
594
+ }
595
+}
596
+
597
+type stubListener struct {
598
+ closeCount int
599
+ addr net.Addr
600
+}
601
+
602
+func (l *stubListener) Accept() (net.Conn, error) {
603
+ return nil, net.ErrClosed
604
+}
605
+
606
+func (l *stubListener) Close() error {
607
+ l.closeCount++
608
+ if l.closeCount > 1 {
609
+ return errors.New("listener closed more than once")
610
+ }
611
+ return nil
612
+}
613
+
614
+func (l *stubListener) Addr() net.Addr {
615
+ if l.addr != nil {
616
+ return l.addr
617
+ }
618
+ return listenerAddr("stub")
619
+}
620
+
621
+type stubConn struct {
622
+ closeCount int
623
+}
624
+
625
+func (c *stubConn) Read(_ []byte) (int, error) { return 0, io.EOF }
626
+func (c *stubConn) Write(b []byte) (int, error) { return len(b), nil }
627
+func (c *stubConn) Close() error { c.closeCount++; return nil }
628
+func (c *stubConn) LocalAddr() net.Addr { return listenerAddr("stub-local") }
629
+func (c *stubConn) RemoteAddr() net.Addr { return listenerAddr("stub-remote") }
630
+func (c *stubConn) SetDeadline(_ time.Time) error { return nil }
631
+func (c *stubConn) SetReadDeadline(_ time.Time) error { return nil }
632
+func (c *stubConn) SetWriteDeadline(_ time.Time) error { return nil }