feat: add default registry

Kim committed Mar 12, 2026 at 09:23 UTC 752e455ceeb5253cc7f3492617e363a5b209c839
11 files changed +163 -137
cmd/demo-app/main.go
+21 -10
@@ -19,21 +19,23 @@ import (
19 )
20
21 var (
22 - flagServerURLs string
23 - flagAddr string
24 - flagName string
25 - flagDesc string
26 - flagTags string
27 - flagOwner string
28 - flagHide bool
29 - flagThumbnail string
22 + flagRelayURLs string
23 + flagDefaultRelays bool
24 + flagAddr string
25 + flagName string
26 + flagDesc string
27 + flagTags string
28 + flagOwner string
29 + flagHide bool
30 + flagThumbnail string
31 )
32
33 func main() {
34 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339})
35 logger := log.With().Str("component", "demo-app").Logger()
36
36 - flag.StringVar(&flagServerURLs, "server-urls", "https://localhost:4017", "relay API URLs (comma-separated; scheme omitted defaults to https)")
37 + flag.StringVar(&flagRelayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; appended to registry.json defaults unless --default-relays=false is set) [env: RELAYS]")
38 + flag.BoolVar(&flagDefaultRelays, "default-relays", utils.ParseBoolEnv("DEFAULT_RELAYS", true), "include repository registry.json default relays [env: DEFAULT_RELAYS]")
39 flag.StringVar(&flagAddr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (host:port or URL; disable if empty)")
40 flag.StringVar(&flagName, "name", "demo-app", "public hostname prefix (single DNS label)")
41 flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
@@ -55,7 +57,16 @@ func runDemo() error {
57 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)
58 defer stop()
59
58 - exposure, err := sdk.Expose(ctx, utils.SplitCSV(flagServerURLs), flagName, types.LeaseMetadata{
60 + relayURLs := utils.SplitCSV(flagRelayURLs)
61 + if flagDefaultRelays {
62 + relayURLs = sdk.WithDefaultRelayURLs(ctx, relayURLs...)
63 + }
64 + relayURLs, err := utils.NormalizeRelayURLs(relayURLs)
65 + if err != nil {
66 + return fmt.Errorf("resolve relay urls: %w", err)
67 + }
68 +
69 + exposure, err := sdk.Expose(ctx, relayURLs, flagName, types.LeaseMetadata{
70 Description: flagDesc,
71 Tags: utils.SplitCSV(flagTags),
72 Owner: flagOwner,
cmd/portal-tunnel/README.md
+6 -2
@@ -17,7 +17,9 @@ Portal-tunnel connects a local service to a Portal relay with the legacy CLI sha
17 ## Flags
18
19 ```text
20 ---relays Portal relay server API URLs (comma-separated, https only) [env: RELAYS]
20 +--relays Additional Portal relay server API URLs (comma-separated, https only; appended to registry.json defaults unless --default-relays=false is set) [env: RELAYS]
21 +--default-relays
22 + Include repository registry.json default relays [env: DEFAULT_RELAYS]
23 --host Target host to proxy to (host:port or URL) [env: APP_HOST]
24 --name Public hostname prefix (single DNS label) [env: APP_NAME]
25 --description Service description metadata [env: APP_DESCRIPTION]
@@ -30,11 +32,13 @@ Portal-tunnel connects a local service to a Portal relay with the legacy CLI sha
32 ## Notes
33
34 - Multiple relay URLs are registered independently. Each relay gets its own lease ID and public URLs.
35 +- The tunnel always starts from the repository-root `registry.json` relay list. `--relays` and `RELAYS` append extra relay URLs on top of those defaults.
36 +- `--default-relays=false` disables the registry defaults and uses only explicit `--relays` or `RELAYS` input.
37 - Relay publishes each service at `<name>.<portal root host>`.
38 - Portal-tunnel now consumes one aggregate SDK listener, so the CLI no longer manages per-relay listener loops itself.
39 - Relay startup and reconnect failures are retried independently in the background. A relay that is down does not stop healthy relays from continuing to serve traffic.
40 - 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.
37 -- The configured relay list stays fixed, but published public URLs appear only for relays that have registered successfully.
41 +- The configured relay list is either `registry.json + explicit relay URLs` or, with `--default-relays=false`, just the explicit relay URLs. Published public URLs appear only for relays that have registered successfully.
42 - SDK callers that do not set `ListenerConfig.RetryCount` use infinite retry semantics for each relay.
43 - Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
44 - When the local service is unreachable, the tunnel returns an HTTP 503 page.
cmd/portal-tunnel/main.go
+22 -15
@@ -21,14 +21,15 @@ import (
21 )
22
23 var (
24 - flagRelayURLs string
25 - flagHost string
26 - flagName string
27 - flagDesc string
28 - flagTags string
29 - flagThumbnail string
30 - flagOwner string
31 - flagHide bool
24 + flagRelayURLs string
25 + flagHost string
26 + flagName string
27 + flagDesc string
28 + flagTags string
29 + flagThumbnail string
30 + flagOwner string
31 + flagHide bool
32 + flagDefaultRelays bool
33 )
34
35 func main() {
@@ -37,18 +38,15 @@ func main() {
38 logger := log.With().Str("component", "portal-tunnel").Logger()
39
40 defaultRelayURLs := os.Getenv("RELAYS")
40 - if defaultRelayURLs == "" {
41 - defaultRelayURLs = "https://localhost:4017"
42 - }
43 -
44 - flag.StringVar(&flagRelayURLs, "relays", defaultRelayURLs, "Portal relay server API URLs (comma-separated; scheme omitted defaults to https) [env: RELAYS]")
41 + flag.StringVar(&flagRelayURLs, "relays", defaultRelayURLs, "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https; appended to registry.json defaults unless --default-relays=false is set) [env: RELAYS]")
42 + flag.BoolVar(&flagDefaultRelays, "default-relays", utils.ParseBoolEnv("DEFAULT_RELAYS", true), "Include repository registry.json default relays [env: DEFAULT_RELAYS]")
43 flag.StringVar(&flagHost, "host", os.Getenv("APP_HOST"), "Target host to proxy to (host:port or URL) [env: APP_HOST]")
44 flag.StringVar(&flagName, "name", os.Getenv("APP_NAME"), "Public hostname prefix (single DNS label) [env: APP_NAME]")
45 flag.StringVar(&flagDesc, "description", os.Getenv("APP_DESCRIPTION"), "Service description metadata [env: APP_DESCRIPTION]")
46 flag.StringVar(&flagTags, "tags", os.Getenv("APP_TAGS"), "Service tags metadata (comma-separated) [env: APP_TAGS]")
47 flag.StringVar(&flagThumbnail, "thumbnail", os.Getenv("APP_THUMBNAIL"), "Service thumbnail URL metadata [env: APP_THUMBNAIL]")
48 flag.StringVar(&flagOwner, "owner", os.Getenv("APP_OWNER"), "Service owner metadata [env: APP_OWNER]")
51 - flag.BoolVar(&flagHide, "hide", os.Getenv("APP_HIDE") == "true", "Hide service from discovery (metadata) [env: APP_HIDE]")
49 + flag.BoolVar(&flagHide, "hide", utils.ParseBoolEnv("APP_HIDE", false), "Hide service from discovery (metadata) [env: APP_HIDE]")
50 flag.Parse()
51
52 if err := runTunnel(); err != nil {
@@ -63,7 +61,16 @@ func runTunnel() error {
61 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
62 defer stop()
63
66 - exposure, err := sdk.Expose(ctx, utils.SplitCSV(flagRelayURLs), flagName, types.LeaseMetadata{
64 + relayURLs := utils.SplitCSV(flagRelayURLs)
65 + if flagDefaultRelays {
66 + relayURLs = sdk.WithDefaultRelayURLs(ctx, relayURLs...)
67 + }
68 + relayURLs, err := utils.NormalizeRelayURLs(relayURLs)
69 + if err != nil {
70 + return fmt.Errorf("resolve relay urls: %w", err)
71 + }
72 +
73 + exposure, err := sdk.Expose(ctx, relayURLs, flagName, types.LeaseMetadata{
74 Description: flagDesc,
75 Tags: utils.SplitCSV(flagTags),
76 Owner: flagOwner,
cmd/relay-server/main.go
+1 -6
@@ -55,7 +55,7 @@ func main() {
55 apiPort := parsePortNumber(os.Getenv("API_PORT"), defaultAPIPort)
56 sniPort := parsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort)
57 adminSecretKey := trimmedEnv("ADMIN_SECRET_KEY")
58 - trustProxyHeaders := parseBoolEnv("TRUST_PROXY_HEADERS")
58 + trustProxyHeaders := utils.ParseBoolEnv("TRUST_PROXY_HEADERS", false)
59 trustedProxyCIDRs := trimmedEnv("TRUSTED_PROXY_CIDRS")
60 keylessDir := trimmedEnv("KEYLESS_DIR")
61 if keylessDir == "" {
@@ -116,11 +116,6 @@ func trimmedEnv(name string) string {
116 return strings.TrimSpace(os.Getenv(name))
117 }
118
119 -func parseBoolEnv(name string) bool {
120 - raw := trimmedEnv(name)
121 - return strings.EqualFold(raw, "true") || raw == "1"
122 -}
123 -
119 func parsePortNumber(raw string, fallback int) int {
120 raw = strings.TrimSpace(raw)
121 if raw == "" {
cmd/relay-server/tunnel.go
+8
@@ -33,6 +33,7 @@ esac
33
34 BASE_URL="${BASE_URL:-%s}"
35 RELAYS="${RELAYS:-$BASE_URL}"
36 +DEFAULT_RELAYS="${DEFAULT_RELAYS:-true}"
37 BIN_URL="${BIN_URL:-$BASE_URL/tunnel/bin/$TUNNEL_OS-$TUNNEL_ARCH}"
38 CHECKSUM_URL="${BIN_URL}.sha256"
39 CURL_INSECURE_FLAG=""
@@ -76,6 +77,9 @@ fi
77 chmod +x "$BIN_PATH"
78
79 set -- "$BIN_PATH" --relays "$RELAYS" --host "${APP_HOST:-localhost:3000}"
80 +if [ "$DEFAULT_RELAYS" = "0" ] || [ "$DEFAULT_RELAYS" = "false" ]; then
81 + set -- "$@" --default-relays=false
82 +fi
83 [ -n "${APP_NAME:-}" ] && set -- "$@" --name "$APP_NAME"
84 [ -n "${APP_DESCRIPTION:-}" ] && set -- "$@" --description "$APP_DESCRIPTION"
85 [ -n "${APP_TAGS:-}" ] && set -- "$@" --tags "$APP_TAGS"
@@ -93,6 +97,7 @@ const tunnelPowerShellScriptTemplate = `$ErrorActionPreference = "Stop"
97
98 $BaseUrl = if ($env:BASE_URL) { $env:BASE_URL } else { "%s" }
99 $RelayUrls = if ($env:RELAYS) { $env:RELAYS } else { $BaseUrl }
100 +$DefaultRelays = if ($env:DEFAULT_RELAYS) { $env:DEFAULT_RELAYS } else { "true" }
101 $OriginalSecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol
102 [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
103
@@ -150,6 +155,9 @@ if ($ActualHash -ne $ExpectedHash) {
155 }
156
157 $ArgsList = @("--relays", $RelayUrls)
158 +if ($DefaultRelays.ToLowerInvariant() -eq "0" -or $DefaultRelays.ToLowerInvariant() -eq "false") {
159 + $ArgsList += "--default-relays=false"
160 +}
161
162 if ($env:APP_HOST) { $ArgsList += "--host", $env:APP_HOST } else { $ArgsList += "--host", "localhost:3000" }
163 if ($env:APP_NAME) { $ArgsList += "--name", $env:APP_NAME }
docs/architecture.md
+3 -1
@@ -54,10 +54,12 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
54
55 ### SDK (`sdk/`)
56
57 +- `WithDefaultRelayURLs`: fetches the default Portal relay list from the repository-root `registry.json`, appends explicit relay inputs, and normalizes the combined list
58 +- Entry points can opt out of registry defaults and call `utils.NormalizeRelayURLs` directly when they need explicit relay inputs only
59 - `Listener`: validates one relay URL locally, then starts relay compatibility checks, lease registration, reverse session maintenance, and lease renewal in the background until ready
60 - `relayclient.go`: internal relay transport helper for control-plane requests and reverse session dialing
61 - `ListenerConfig.RetryCount <= 0` means retry forever; positive values close the listener after the retry budget is exhausted
60 -- Default app flow is `RelayURL -> NewListener -> PublicURL -> http.Server.Serve(listener)` or `RelayURLs -> Expose -> PublicURLs -> http.Server.Serve(exposure)`
62 +- Default app flow is `WithDefaultRelayURLs -> NewListener -> PublicURL -> http.Server.Serve(listener)` or `WithDefaultRelayURLs -> Expose -> PublicURLs -> http.Server.Serve(exposure)`, with an opt-out path for explicit relay inputs only
63 - `expose.go`: optional `RunHTTP` helper for serving one handler on both a local HTTP port and the relay listener
64 - `Expose` keeps one listener per configured relay URL. Relay startup and reconnect failures are retried independently per relay, and successful relays remain available while failed relays keep retrying in the background
65 - `Exposure.RelayURLs()` returns the configured normalized relay URLs, while `Exposure.PublicURLs()` returns only relays that are currently registered and ready
frontend/src/components/TunnelCommandModal.tsx
+24 -6
@@ -31,6 +31,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
31 const [host, setHost] = useState(defaultHost);
32 const [name, setName] = useState(defaultName);
33 const [relayUrls, setRelayUrls] = useState<string[]>([currentOrigin]);
34 + const [defaultRelays, setDefaultRelays] = useState(true);
35 const [urlInput, setUrlInput] = useState("");
36 const [copied, setCopied] = useState(false);
37 const [os, setOs] = useState<"unix" | "windows">("unix");
@@ -98,6 +99,9 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
99 `$env:APP_NAME=${quotePowerShellValue(nameVal)}`,
100 `$env:RELAYS=${quotePowerShellValue(relayUrlVal)}`,
101 ];
102 + if (!defaultRelays) {
103 + envAssignments.push("$env:DEFAULT_RELAYS='false'");
104 + }
105 if (normalizedThumbnailURL) {
106 envAssignments.push(
107 `$env:APP_THUMBNAIL=${quotePowerShellValue(normalizedThumbnailURL)}`
@@ -114,6 +118,9 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
118 `APP_NAME=${quoteShellValue(nameVal)}`,
119 `RELAYS=${quoteShellValue(relayUrlVal)}`,
120 ];
121 + if (!defaultRelays) {
122 + envAssignments.push(`DEFAULT_RELAYS=${quoteShellValue("false")}`);
123 + }
124 if (normalizedThumbnailURL) {
125 envAssignments.push(
126 `APP_THUMBNAIL=${quoteShellValue(normalizedThumbnailURL)}`
@@ -122,7 +129,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
129 return `curl ${curlFlags} ${quoteShellValue(
130 tunnelScriptURL
131 )} | ${envAssignments.join(" ")} sh`;
125 - }, [currentOrigin, host, name, normalizedThumbnailURL, relayUrls, os]);
132 + }, [currentOrigin, defaultRelays, host, name, normalizedThumbnailURL, relayUrls, os]);
133
134 const handleCopy = async () => {
135 try {
@@ -196,9 +203,20 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
203
204 {/* Relay URLs Input */}
205 <div className="space-y-2">
199 - <label className="text-sm font-medium text-foreground">
200 - Relay URLs
201 - </label>
206 + <div className="flex items-center justify-between gap-3">
207 + <label className="text-sm font-medium text-foreground">
208 + Relay URLs
209 + </label>
210 + <label className="flex items-center gap-2 text-xs text-muted-foreground">
211 + <input
212 + type="checkbox"
213 + checked={defaultRelays}
214 + onChange={(e) => setDefaultRelays(e.target.checked)}
215 + className="h-4 w-4"
216 + />
217 + <span>Include default registry</span>
218 + </label>
219 + </div>
220 <div className="flex flex-wrap items-center gap-2 rounded-md border border-input bg-transparent p-2 min-h-10">
221 {relayUrls.map((url) => (
222 <span
@@ -223,9 +241,9 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
241 onKeyDown={handleUrlKeyDown}
242 placeholder="Add relay URL..."
243 className="min-w-[140px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
226 - />
244 + />
245 + </div>
246 </div>
228 - </div>
247
248 <div className="space-y-2">
249 <label
registry.json new
+10
@@ -0,0 +1,10 @@
1 +{
2 + "relays": [
3 + "https://gosunuts.xyz/",
4 + "https://portal.thumbgo.kr/",
5 + "https://portal.rabbitson87.dev/",
6 + "https://s-h.day/",
7 + "https://portal.dawnfullstack.com/",
8 + "https://portal.hontoni.moe/"
9 + ]
10 +}
\ No newline at end of file
sdk/registry.go new
+53
@@ -0,0 +1,53 @@
1 +package sdk
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "net/http"
7 +
8 + "github.com/gosuda/portal/v2/utils"
9 +)
10 +
11 +const PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal/main/registry.json"
12 +
13 +// WithDefaultRelayURLs fetches the default Portal relay registry and appends
14 +// any explicit relay inputs before normalization.
15 +func WithDefaultRelayURLs(ctx context.Context, explicit ...string) []string {
16 + if ctx == nil {
17 + ctx = context.Background()
18 + }
19 +
20 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, PortalRelayRegistryURL, nil)
21 + if err != nil {
22 + return explicit
23 + }
24 +
25 + client := &http.Client{Timeout: defaultRequestTimeout}
26 + resp, err := client.Do(req)
27 + if err != nil {
28 + return explicit
29 + }
30 + defer resp.Body.Close()
31 +
32 + if resp.StatusCode != http.StatusOK {
33 + return explicit
34 + }
35 +
36 + var registry struct {
37 + Relays []string `json:"relays"`
38 + }
39 + if err := json.NewDecoder(resp.Body).Decode(&registry); err != nil {
40 + return explicit
41 + }
42 +
43 + relayURLs := append(registry.Relays, explicit...)
44 + if len(relayURLs) == 0 {
45 + return nil
46 + }
47 + relayURLs, err = utils.NormalizeRelayURLs(relayURLs)
48 + if err != nil {
49 + return nil
50 + }
51 +
52 + return relayURLs
53 +}
sdk/sdk_test.go
-97
@@ -5,7 +5,6 @@ import (
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 - "strings"
8 "sync/atomic"
9 "testing"
10 "time"
@@ -347,102 +346,6 @@ func TestNewListenerRetriesForeverWhenRetryCountIsNegative(t *testing.T) {
346 }
347 }
348
350 -func TestExposeAddsRecoveredRelayWithoutDroppingHealthyRelay(t *testing.T) {
351 - goodServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
352 - switch r.URL.Path {
353 - case types.PathSDKDomain:
354 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
355 - OK: true,
356 - Data: types.DomainResponse{
357 - Version: types.SDKProtocolVersion,
358 - },
359 - })
360 - case types.PathSDKRegister:
361 - writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
362 - OK: true,
363 - Data: types.RegisterResponse{
364 - LeaseID: "lease-good",
365 - Hostname: "127.0.0.1",
366 - },
367 - })
368 - case types.PathSDKConnect:
369 - writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
370 - OK: false,
371 - Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "not used in test"},
372 - })
373 - case types.PathSDKRenew:
374 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
375 - OK: true,
376 - Data: types.RenewResponse{LeaseID: "lease-good"},
377 - })
378 - case types.PathSDKUnregister:
379 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
380 - default:
381 - http.NotFound(w, r)
382 - }
383 - }))
384 - defer goodServer.Close()
385 -
386 - var delayedDomainCount atomic.Int32
387 - delayedServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
388 - switch r.URL.Path {
389 - case types.PathSDKDomain:
390 - if delayedDomainCount.Add(1) == 1 {
391 - http.Error(w, "temporarily unavailable", http.StatusBadGateway)
392 - return
393 - }
394 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
395 - OK: true,
396 - Data: types.DomainResponse{
397 - Version: types.SDKProtocolVersion,
398 - },
399 - })
400 - case types.PathSDKRegister:
401 - writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
402 - OK: true,
403 - Data: types.RegisterResponse{
404 - LeaseID: "lease-delayed",
405 - Hostname: "127.0.0.2",
406 - },
407 - })
408 - case types.PathSDKConnect:
409 - writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
410 - OK: false,
411 - Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "not used in test"},
412 - })
413 - case types.PathSDKRenew:
414 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
415 - OK: true,
416 - Data: types.RenewResponse{LeaseID: "lease-delayed"},
417 - })
418 - case types.PathSDKUnregister:
419 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
420 - default:
421 - http.NotFound(w, r)
422 - }
423 - }))
424 - defer delayedServer.Close()
425 -
426 - exposure, err := Expose(context.Background(), []string{goodServer.URL, delayedServer.URL}, "demo", types.LeaseMetadata{})
427 - if err != nil {
428 - t.Fatalf("Expose() error = %v", err)
429 - }
430 - if exposure == nil {
431 - t.Fatal("Expose() exposure = nil, want non-nil")
432 - }
433 - defer exposure.Close()
434 -
435 - waitForSDKTest(t, func() bool {
436 - return len(exposure.PublicURLs()) >= 1
437 - })
438 - waitForSDKTestWithTimeout(t, 15*time.Second, func() bool {
439 - return delayedDomainCount.Load() >= 2 &&
440 - len(exposure.PublicURLs()) == 2 &&
441 - strings.Contains(exposure.Addr().String(), "lease-good") &&
442 - strings.Contains(exposure.Addr().String(), "lease-delayed")
443 - })
444 -}
445 -
349 func TestExposeNoRelayInputs(t *testing.T) {
350 exposure, err := Expose(context.Background(), nil, "demo", types.LeaseMetadata{})
351 if err != nil {
utils/utils.go
+15
@@ -10,6 +10,8 @@ import (
10 "fmt"
11 "net"
12 "net/url"
13 + "os"
14 + "strconv"
15 "strings"
16 "time"
17 )
@@ -263,6 +265,19 @@ func IntOrDefault(v, fallback int) int {
265 return fallback
266 }
267
268 +// ParseBoolEnv reads a boolean environment variable and falls back when unset or invalid.
269 +func ParseBoolEnv(name string, fallback bool) bool {
270 + raw := strings.TrimSpace(os.Getenv(name))
271 + if raw == "" {
272 + return fallback
273 + }
274 + parsed, err := strconv.ParseBool(raw)
275 + if err != nil {
276 + return fallback
277 + }
278 + return parsed
279 +}
280 +
281 // Random value helpers.
282 func RandomID(prefix string) string {
283 buf := make([]byte, 8)