feat: enhance relay selection logic, add max retry for discovery listener

Kim committed Apr 13, 2026 at 11:47 UTC 41a9f5b3abd41848e1543e1fb7ddeefc96718d95
13 files changed +373 -606
cmd/demo-app/main.go
+1 -1
@@ -57,7 +57,7 @@ func registerConnectivityFlags(fs *flag.FlagSet, cfg *demoConfig, defaultRelays
57 utils.BoolFlagEnv(fs, &cfg.banMITM, "ban-mitm", false, "ban relay when the MITM self-probe detects TLS termination", "BAN_MITM")
58 utils.StringFlagEnv(fs, &cfg.identityPath, "identity-path", "identity.json", "identity json file path", "IDENTITY_PATH")
59 utils.StringFlagEnv(fs, &cfg.identityJSON, "identity-json", "", "identity json payload; overrides --identity-path contents and is persisted there when both are set", "IDENTITY_JSON")
60 - utils.IntFlagEnv(fs, &cfg.maxActiveRelays, "max-active-relays", 3, nil, "maximum number of active relays to keep connected", "MAX_ACTIVE_RELAYS")
60 + utils.IntFlagEnv(fs, &cfg.maxActiveRelays, "max-active-relays", 3, nil, "maximum number of auto-selected relays to keep connected; explicit --relays are always included", "MAX_ACTIVE_RELAYS")
61 utils.StringFlag(fs, &cfg.owner, "owner", "PortalApp Developer", "lease owner")
62 }
63
cmd/portal-tunnel/README.md
+5 -3
@@ -71,7 +71,7 @@ portal expose --name myapp \
71 - Route matching is longest-prefix-first. `/api=http://127.0.0.1:3001` matches `/api/*` and strips the `/api` prefix before proxying to the upstream.
72 - Routed HTTP mode automatically forwards `X-Forwarded-*`, rewrites upstream `Location` redirects back to the public route path, and strips loopback cookie domains while remapping cookie paths to the mounted route prefix.
73 - `--name` is optional. When omitted, the CLI generates a name for that run.
74 -- `--relays` adds explicit relay API URLs for that run.
74 +- `--relays` adds explicit relay API URLs for that run. Explicit relays are always kept connected and are not counted against `--max-active-relays`.
75 - `--discovery=false` disables the public registry seed list and the runtime relay discovery expansion loop for that run. With `--discovery=false`, only the explicit `--relays` values are used.
76 - `--ban-mitm` enables strict rejection when the TLS self-probe detects termination in the path.
77 - `--tcp` requests a dedicated TCP port on the relay for raw TCP services that do not use TLS (e.g., Minecraft, game servers).
@@ -81,6 +81,7 @@ Flags:
81 ```text
82 --relays Portal relay API URLs (comma-separated, https only)
83 --discovery Include public registry relays and discover additional relay bootstraps
84 +--max-active-relays Maximum number of auto-selected relays; explicit --relays are always included
85 --ban-mitm Ban relay when the MITM self-probe detects TLS termination
86 --identity-path Identity JSON file path; created automatically when missing
87 --name Public hostname prefix (single DNS label); auto-generated when omitted
@@ -122,8 +123,9 @@ Legacy execution compatibility has been removed:
123 - The tunnel consumes one aggregate SDK listener, so the CLI no longer manages per-relay listener loops itself.
124 - 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.
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.
125 -- With discovery enabled, the configured relay list starts with `public registry + --relays values` and can expand through relay discovery. With `--discovery=false`, only the explicit relay URLs are used. Published public URLs appear only for relays that have registered successfully.
126 -- SDK callers that do not set `ListenerConfig.RetryCount` use infinite retry semantics for each relay.
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.
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.
cmd/portal-tunnel/main.go
+1 -1
@@ -71,7 +71,7 @@ func runExposeCommand(args []string) error {
71 utils.BoolFlagEnv(fs, &flags.udp, "udp", false, "Enable public UDP relay in addition to the default TCP relay", "UDP_ENABLED")
72 utils.StringFlagEnv(fs, &flags.udpAddr, "udp-addr", "", "Local UDP target address for relayed datagrams (host:port or port only); defaults to the target when --udp is enabled", "UDP_ADDR")
73 utils.BoolFlagEnv(fs, &flags.tcp, "tcp", false, "Request a dedicated TCP port on the relay for raw TCP services (no TLS; e.g., Minecraft, game servers)", "TCP_ENABLED")
74 - utils.IntFlagEnv(fs, &flags.maxActiveRelays, "max-active-relays", 3, nil, "Maximum number of active relays to keep connected", "MAX_ACTIVE_RELAYS")
74 + utils.IntFlagEnv(fs, &flags.maxActiveRelays, "max-active-relays", 3, nil, "Maximum number of auto-selected relays to keep connected; explicit --relays are always included", "MAX_ACTIVE_RELAYS")
75
76 if err := utils.ParseFlagSet(fs, args, printExposeUsage); err != nil {
77 if errors.Is(err, flag.ErrHelp) {
portal/discovery/policy.go
+59 -80
@@ -2,8 +2,9 @@ package discovery
2
3 import (
4 "errors"
5 + "math/rand"
6 "net/http"
6 - "sort"
7 + "slices"
8 "strings"
9 "time"
10
@@ -13,7 +14,7 @@ import (
14 type RelayPolicy interface {
15 SelectActive([]RelayState) []RelayState
16 SelectConfirmed([]RelayState) []RelayState
16 - SelectPriority([]RelayState, ClientState) []RelayState
17 + SelectPriority([]RelayState, ClientState) []string
18 OnConfirmed(RelayState) RelayState
19 OnHinted(RelayState) RelayState
20 OnFailure(RelayState, error, int) (RelayState, bool, string)
@@ -22,6 +23,8 @@ type RelayPolicy interface {
23
24 type DefaultRelayPolicy struct{}
25
26 +const highDiscoveryRTTThreshold = 1 * time.Second
27 +
28 func (p DefaultRelayPolicy) selectStates(states []RelayState, keep func(RelayState) bool) []RelayState {
29 now := time.Now().UTC()
30 out := make([]RelayState, 0, len(states))
@@ -52,106 +55,82 @@ func (p DefaultRelayPolicy) SelectConfirmed(states []RelayState) []RelayState {
55 })
56 }
57
55 -func (p DefaultRelayPolicy) SelectPriority(states []RelayState, clientState ClientState) []RelayState {
58 +func (p DefaultRelayPolicy) SelectPriority(states []RelayState, clientState ClientState) []string {
59 selected := p.SelectActive(states)
60 if len(selected) == 0 {
61 return nil
62 }
63
61 - currentRelayURLs := make([]string, 0, len(clientState.ActiveRelayURLs))
62 - activeRelayURLSet := make(map[string]struct{}, len(clientState.ActiveRelayURLs))
63 - for _, relayURL := range clientState.ActiveRelayURLs {
64 - relayURL = strings.TrimSpace(relayURL)
65 - if relayURL == "" {
66 - continue
67 - }
68 - currentRelayURLs = append(currentRelayURLs, relayURL)
69 - activeRelayURLSet[relayURL] = struct{}{}
70 - }
71 -
72 - out := selected[:0]
64 + explicit := make([]string, 0, len(clientState.ExplicitRelayURLs))
65 + autoPool := make([]RelayState, 0, len(selected))
66 for _, state := range selected {
74 - if state.consecutiveFailures > 0 && !state.Reachable {
75 - continue
76 - }
67 if clientState.RequireUDP && state.hasDescriptor() && !state.Descriptor.SupportsUDP {
68 continue
69 }
70 if clientState.RequireTCP && state.hasDescriptor() && !state.Descriptor.SupportsTCP {
71 continue
72 }
83 - out = append(out, state)
73 + relayURL := strings.TrimSpace(state.Descriptor.APIHTTPSAddr)
74 + if slices.Contains(clientState.ExplicitRelayURLs, relayURL) {
75 + explicit = append(explicit, relayURL)
76 + continue
77 + }
78 + autoPool = append(autoPool, state)
79 }
85 - if len(out) == 0 {
80 + if len(explicit) == 0 && len(autoPool) == 0 {
81 return nil
82 }
83
89 - currentRelays := make([]RelayState, 0, len(currentRelayURLs))
90 - eligibleByURL := make(map[string]RelayState, len(out))
91 - for _, state := range out {
92 - eligibleByURL[strings.TrimSpace(state.Descriptor.APIHTTPSAddr)] = state
84 + currentAuto := make([]string, 0, len(autoPool))
85 + remainingAuto := make([]string, 0, len(autoPool))
86 + highRTTAuto := make([]string, 0, len(autoPool))
87 + penalizedAuto := make([]string, 0, len(autoPool))
88 + for _, state := range autoPool {
89 + relayURL := strings.TrimSpace(state.Descriptor.APIHTTPSAddr)
90 + statePenalized := state.consecutiveFailures > 0 && !state.Reachable
91 + switch {
92 + case slices.Contains(clientState.ActiveRelayURLs, relayURL) && !statePenalized:
93 + currentAuto = append(currentAuto, relayURL)
94 + case statePenalized:
95 + penalizedAuto = append(penalizedAuto, relayURL)
96 + case !state.DiscoveryRTTAt.IsZero() && state.DiscoveryRTT > highDiscoveryRTTThreshold:
97 + highRTTAuto = append(highRTTAuto, relayURL)
98 + default:
99 + remainingAuto = append(remainingAuto, relayURL)
100 + }
101 }
102
95 - currentHealthy := len(currentRelayURLs) > 0
96 - for _, relayURL := range currentRelayURLs {
97 - state, ok := eligibleByURL[relayURL]
98 - if !ok {
99 - currentHealthy = false
100 - break
101 - }
102 - currentRelays = append(currentRelays, state)
103 + rng := rand.New(rand.NewSource(time.Now().UnixNano()))
104 + if len(remainingAuto) > 1 {
105 + rng.Shuffle(len(remainingAuto), func(i, j int) {
106 + remainingAuto[i], remainingAuto[j] = remainingAuto[j], remainingAuto[i]
107 + })
108 }
104 - if currentHealthy && (clientState.MaxActiveRelays <= 0 || len(currentRelays) >= clientState.MaxActiveRelays) {
105 - return currentRelays
109 + if len(penalizedAuto) > 1 {
110 + rng.Shuffle(len(penalizedAuto), func(i, j int) {
111 + penalizedAuto[i], penalizedAuto[j] = penalizedAuto[j], penalizedAuto[i]
112 + })
113 + }
114 + if len(highRTTAuto) > 1 {
115 + rng.Shuffle(len(highRTTAuto), func(i, j int) {
116 + highRTTAuto[i], highRTTAuto[j] = highRTTAuto[j], highRTTAuto[i]
117 + })
118 }
119
108 - sort.SliceStable(out, func(i, j int) bool {
109 - left := out[i]
110 - right := out[j]
111 -
112 - // 1. Prefer confirmed relays over candidates that are only known through bootstrap discovery.
113 - if left.Confirmed != right.Confirmed {
114 - return left.Confirmed
115 - }
116 -
117 - // 2. Prefer relays the client is already using so a healthy pool stays stable instead of churning.
118 - _, leftActive := activeRelayURLSet[strings.TrimSpace(left.Descriptor.APIHTTPSAddr)]
119 - _, rightActive := activeRelayURLSet[strings.TrimSpace(right.Descriptor.APIHTTPSAddr)]
120 - if leftActive != rightActive {
121 - return leftActive
122 - }
123 -
124 - // 3. Prefer bootstrap relays when the stronger signals above are equal.
125 - if left.Bootstrap != right.Bootstrap {
126 - return left.Bootstrap
127 - }
128 -
129 - // 4. Prefer relays reporting lower concurrent load.
130 - if left.Descriptor.Load != right.Descriptor.Load {
131 - return left.Descriptor.Load < right.Descriptor.Load
132 - }
133 -
134 - // 5. Prefer relays reporting lower traffic score after the more stable load signal ties.
135 - if left.Descriptor.LoadScore != right.Descriptor.LoadScore {
136 - return left.Descriptor.LoadScore < right.Descriptor.LoadScore
137 - }
138 -
139 - // 6. Prefer relays with measured discovery RTT, then prefer the lower RTT when both have measurements.
140 - leftHasRTT := !left.DiscoveryRTTAt.IsZero()
141 - rightHasRTT := !right.DiscoveryRTTAt.IsZero()
142 - if leftHasRTT != rightHasRTT {
143 - return leftHasRTT
144 - }
145 - if left.DiscoveryRTT != right.DiscoveryRTT {
146 - return left.DiscoveryRTT < right.DiscoveryRTT
147 - }
148 -
149 - // 7. Fall back to URL ordering so selection remains deterministic when all policy signals tie.
150 - return left.Descriptor.APIHTTPSAddr < right.Descriptor.APIHTTPSAddr
151 - })
120 + autoURLs := make([]string, 0, len(currentAuto)+len(remainingAuto)+len(highRTTAuto)+len(penalizedAuto))
121 + autoURLs = append(autoURLs, currentAuto...)
122 + autoURLs = append(autoURLs, remainingAuto...)
123 + autoURLs = append(autoURLs, highRTTAuto...)
124 + autoURLs = append(autoURLs, penalizedAuto...)
125 + if clientState.MaxActiveRelays > 0 && len(autoURLs) > clientState.MaxActiveRelays {
126 + autoURLs = autoURLs[:clientState.MaxActiveRelays]
127 + }
128
153 - if clientState.MaxActiveRelays > 0 && len(out) > clientState.MaxActiveRelays {
154 - out = out[:clientState.MaxActiveRelays]
129 + out := make([]string, 0, len(explicit)+len(autoURLs))
130 + out = append(out, explicit...)
131 + out = append(out, autoURLs...)
132 + if len(out) == 0 {
133 + return nil
134 }
135 return out
136 }
portal/discovery/policy_test.go new
+169
@@ -0,0 +1,169 @@
1 +package discovery
2 +
3 +import (
4 + "testing"
5 + "time"
6 +
7 + "github.com/gosuda/portal-tunnel/v2/types"
8 + "github.com/gosuda/portal-tunnel/v2/utils"
9 +)
10 +
11 +func mustPolicyRelayDescriptor(t *testing.T, relayName, relayURL string) types.RelayDescriptor {
12 + t.Helper()
13 +
14 + now := time.Now().UTC()
15 + desc, err := utils.NormalizeDescriptor(types.RelayDescriptor{
16 + Identity: types.Identity{
17 + Name: relayName,
18 + },
19 + RelayID: relayURL,
20 + Version: 1,
21 + IssuedAt: now,
22 + ExpiresAt: now.Add(time.Hour),
23 + APIHTTPSAddr: relayURL,
24 + })
25 + if err != nil {
26 + t.Fatalf("NormalizeDescriptor() error = %v", err)
27 + }
28 + return desc
29 +}
30 +
31 +func bootstrapPolicyRelayState(relayURL string) RelayState {
32 + return RelayState{
33 + Descriptor: types.RelayDescriptor{
34 + Identity: types.Identity{
35 + Name: utils.PortalRootHost(relayURL),
36 + },
37 + RelayID: relayURL,
38 + APIHTTPSAddr: relayURL,
39 + },
40 + Bootstrap: true,
41 + }
42 +}
43 +
44 +func confirmedPolicyRelayState(t *testing.T, relayName, relayURL string) RelayState {
45 + t.Helper()
46 +
47 + return RelayState{
48 + Descriptor: mustPolicyRelayDescriptor(t, relayName, relayURL),
49 + Reachable: true,
50 + Confirmed: true,
51 + LastSeenAt: time.Now().UTC(),
52 + }
53 +}
54 +
55 +func confirmedPolicyRelayStateWithRTT(t *testing.T, relayName, relayURL string, rtt time.Duration) RelayState {
56 + t.Helper()
57 +
58 + state := confirmedPolicyRelayState(t, relayName, relayURL)
59 + state.DiscoveryRTT = rtt
60 + state.DiscoveryRTTAt = time.Now().UTC()
61 + return state
62 +}
63 +
64 +func TestSelectPriorityKeepsExplicitRelaysOutsideAutoLimit(t *testing.T) {
65 + policy := DefaultRelayPolicy{}
66 + explicitRelay := "https://relay-explicit.example"
67 + relayA := "https://relay-a.example"
68 + relayB := "https://relay-b.example"
69 +
70 + selected := policy.SelectPriority([]RelayState{
71 + bootstrapPolicyRelayState(explicitRelay),
72 + confirmedPolicyRelayState(t, "relay-a", relayA),
73 + confirmedPolicyRelayState(t, "relay-b", relayB),
74 + }, ClientState{
75 + ExplicitRelayURLs: []string{explicitRelay},
76 + MaxActiveRelays: 1,
77 + })
78 +
79 + if len(selected) != 2 {
80 + t.Fatalf("len(selected) = %d, want 2", len(selected))
81 + }
82 + if got := selected[0]; got != explicitRelay {
83 + t.Fatalf("selected[0] = %q, want explicit relay %q", got, explicitRelay)
84 + }
85 +}
86 +
87 +func TestSelectPriorityColdStartSelectsEligibleRelay(t *testing.T) {
88 + policy := DefaultRelayPolicy{}
89 + relayA := "https://relay-a.example"
90 + relayB := "https://relay-b.example"
91 +
92 + selected := policy.SelectPriority([]RelayState{
93 + confirmedPolicyRelayState(t, "relay-a", relayA),
94 + confirmedPolicyRelayState(t, "relay-b", relayB),
95 + }, ClientState{
96 + MaxActiveRelays: 1,
97 + })
98 +
99 + if len(selected) != 1 {
100 + t.Fatalf("len(selected) = %d, want 1", len(selected))
101 + }
102 + if got := selected[0]; got != relayA && got != relayB {
103 + t.Fatalf("selected[0] = %q, want one of %q or %q", got, relayA, relayB)
104 + }
105 +}
106 +
107 +func TestSelectPriorityKeepsCurrentHealthyRelayOverNewConfirmedRelay(t *testing.T) {
108 + policy := DefaultRelayPolicy{}
109 + currentRelay := "https://relay-current.example"
110 + newRelay := "https://relay-new.example"
111 +
112 + selected := policy.SelectPriority([]RelayState{
113 + bootstrapPolicyRelayState(currentRelay),
114 + confirmedPolicyRelayState(t, "relay-new", newRelay),
115 + }, ClientState{
116 + ActiveRelayURLs: []string{currentRelay},
117 + MaxActiveRelays: 1,
118 + })
119 +
120 + if len(selected) != 1 {
121 + t.Fatalf("len(selected) = %d, want 1", len(selected))
122 + }
123 + if got := selected[0]; got != currentRelay {
124 + t.Fatalf("selected[0] = %q, want current healthy relay %q kept", got, currentRelay)
125 + }
126 +}
127 +
128 +func TestSelectPriorityPushesHighRTTRelayBehindNormalRelay(t *testing.T) {
129 + policy := DefaultRelayPolicy{}
130 + normalRelay := confirmedPolicyRelayStateWithRTT(t, "relay-normal", "https://relay-normal.example", 200*time.Millisecond)
131 + highRTTRelay := confirmedPolicyRelayStateWithRTT(t, "relay-high-rtt", "https://relay-high-rtt.example", 1500*time.Millisecond)
132 +
133 + selected := policy.SelectPriority([]RelayState{
134 + highRTTRelay,
135 + normalRelay,
136 + }, ClientState{
137 + MaxActiveRelays: 1,
138 + })
139 +
140 + if len(selected) != 1 {
141 + t.Fatalf("len(selected) = %d, want 1", len(selected))
142 + }
143 + if got := selected[0]; got != normalRelay.Descriptor.APIHTTPSAddr {
144 + t.Fatalf("selected[0] = %q, want normal RTT relay %q", got, normalRelay.Descriptor.APIHTTPSAddr)
145 + }
146 +}
147 +
148 +func TestSelectPriorityReplacesCurrentDeadRelay(t *testing.T) {
149 + policy := DefaultRelayPolicy{}
150 + currentRelay := bootstrapPolicyRelayState("https://relay-current.example")
151 + currentRelay.Reachable = false
152 + currentRelay.consecutiveFailures = 1
153 +
154 + replacementRelay := confirmedPolicyRelayState(t, "relay-new", "https://relay-new.example")
155 + selected := policy.SelectPriority([]RelayState{
156 + currentRelay,
157 + replacementRelay,
158 + }, ClientState{
159 + ActiveRelayURLs: []string{currentRelay.Descriptor.APIHTTPSAddr},
160 + MaxActiveRelays: 1,
161 + })
162 +
163 + if len(selected) != 1 {
164 + t.Fatalf("len(selected) = %d, want 1", len(selected))
165 + }
166 + if got := selected[0]; got != replacementRelay.Descriptor.APIHTTPSAddr {
167 + t.Fatalf("selected[0] = %q, want replacement relay %q", got, replacementRelay.Descriptor.APIHTTPSAddr)
168 + }
169 +}
portal/discovery/refresher.go
+1 -1
@@ -17,7 +17,7 @@ import (
17
18 const (
19 defaultRequestTimeout = 15 * time.Second
20 - DiscoveryPollInterval = 1 * time.Minute
20 + DiscoveryPollInterval = 30 * time.Second
21 defaultRecoveryFailures = 3
22 )
23
portal/discovery/relayset.go
+1 -1
@@ -102,7 +102,7 @@ func (s *RelaySet) ActiveRelays() []RelayState {
102 return s.policy.SelectActive(s.relayStatesLocked())
103 }
104
105 -func (s *RelaySet) PriorityRelays(clientState ClientState) []RelayState {
105 +func (s *RelaySet) PriorityRelays(clientState ClientState) []string {
106 s.mu.RLock()
107 defer s.mu.RUnlock()
108
portal/discovery/relayset_test.go new
+31
@@ -0,0 +1,31 @@
1 +package discovery
2 +
3 +import (
4 + "testing"
5 + "time"
6 +
7 + "github.com/gosuda/portal-tunnel/v2/types"
8 +)
9 +
10 +func TestApplyRelayDiscoveryResponseAllowsURLChangeForSameIdentity(t *testing.T) {
11 + set, err := NewRelaySet(types.Identity{}, "", nil)
12 + if err != nil {
13 + t.Fatalf("NewRelaySet() error = %v", err)
14 + }
15 +
16 + desc := mustPolicyRelayDescriptor(t, "relay-a", "https://relay-a.example")
17 + if _, err := set.ApplyRelayDiscoveryResponse(desc.Identity, desc.APIHTTPSAddr, types.DiscoveryResponse{
18 + ProtocolVersion: types.ProtocolVersion,
19 + Self: desc,
20 + }, time.Now().UTC()); err != nil {
21 + t.Fatalf("ApplyRelayDiscoveryResponse() error = %v", err)
22 + }
23 +
24 + changedURL := mustPolicyRelayDescriptor(t, desc.Name, "https://relay-b.example")
25 + if _, err := set.ApplyRelayDiscoveryResponse(desc.Identity, "", types.DiscoveryResponse{
26 + ProtocolVersion: types.ProtocolVersion,
27 + Self: changedURL,
28 + }, time.Now().UTC()); err != nil {
29 + t.Fatalf("ApplyRelayDiscoveryResponse() error = %v, want nil for same relay identity", err)
30 + }
31 +}
portal/discovery/relaystate.go
+5 -4
@@ -23,10 +23,11 @@ type RelayState struct {
23 }
24
25 type ClientState struct {
26 - ActiveRelayURLs []string
27 - MaxActiveRelays int
28 - RequireUDP bool
29 - RequireTCP bool
26 + ActiveRelayURLs []string
27 + ExplicitRelayURLs []string
28 + MaxActiveRelays int
29 + RequireUDP bool
30 + RequireTCP bool
31 }
32
33 func newRelayState(desc types.RelayDescriptor, seenAt time.Time) (RelayState, error) {
registry.json
+4 -1
@@ -5,6 +5,9 @@
5 "https://s-h.day/",
6 "https://portal.1ncursio.dev/",
7 "https://portal.dawnfullstack.com/",
8 - "https://portal.damn.it.com/"
8 + "https://portal.damn.it.com/",
9 + "https://portal.korokorok.com/",
10 + "https://kakashit.org/",
11 + "https://portal.tyutya.top/"
12 ]
13 }
\ No newline at end of file
sdk/expose.go
+60 -24
@@ -6,7 +6,7 @@ import (
6 "fmt"
7 "net"
8 "net/http"
9 - "sort"
9 + "slices"
10 "strings"
11 "sync"
12 "sync/atomic"
@@ -26,6 +26,8 @@ type Exposure struct {
26 done <-chan struct{}
27
28 identity types.Identity
29 + explicitRelays []string
30 + activeRelayURLs []string
31 TargetAddr string
32 UDPAddr string
33 udpEnabled bool
@@ -65,10 +67,17 @@ type ExposeConfig struct {
67 // Expose creates relay listeners for the selected relay pool and exposes a
68 // dynamic listener hub for accepting traffic from all of them.
69 func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
68 - relayURLs, err := utils.ResolvePortalRelayURLs(ctx, cfg.RelayURLs, cfg.Discovery)
70 + explicitRelayURLs, err := utils.NormalizeRelayURLs(cfg.RelayURLs...)
71 if err != nil {
72 return nil, err
73 }
74 + relayURLs := explicitRelayURLs
75 + if cfg.Discovery {
76 + relayURLs, err = utils.ResolvePortalRelayURLs(ctx, explicitRelayURLs, true)
77 + if err != nil {
78 + return nil, err
79 + }
80 + }
81
82 identity, createdIdentity, err := utils.ResolveListenerIdentity(
83 types.Identity{Name: cfg.Name},
@@ -106,6 +115,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
115 cancel: cancel,
116 done: exposureCtx.Done(),
117 identity: identity,
118 + explicitRelays: append([]string(nil), explicitRelayURLs...),
119 TargetAddr: targetAddr,
120 UDPAddr: udpAddr,
121 udpEnabled: cfg.UDPEnabled,
@@ -163,24 +173,22 @@ func (e *Exposure) runDiscoveryLoop(ctx context.Context) {
173 }
174
175 func (e *Exposure) ActiveRelayURLs() []string {
166 - return append([]string(nil), e.clientState().ActiveRelayURLs...)
176 + e.listenerMu.RLock()
177 + defer e.listenerMu.RUnlock()
178 + return append([]string(nil), e.activeRelayURLs...)
179 }
180
181 func (e *Exposure) clientState() discovery.ClientState {
170 - state := discovery.ClientState{
171 - MaxActiveRelays: e.maxActiveRelays,
172 - RequireUDP: e.udpEnabled,
173 - RequireTCP: e.tcpEnabled,
174 - }
175 -
182 e.listenerMu.RLock()
177 - state.ActiveRelayURLs = make([]string, 0, len(e.relayListeners))
178 - for relayURL := range e.relayListeners {
179 - state.ActiveRelayURLs = append(state.ActiveRelayURLs, relayURL)
183 + defer e.listenerMu.RUnlock()
184 +
185 + return discovery.ClientState{
186 + ActiveRelayURLs: append([]string(nil), e.activeRelayURLs...),
187 + ExplicitRelayURLs: append([]string(nil), e.explicitRelays...),
188 + MaxActiveRelays: e.maxActiveRelays,
189 + RequireUDP: e.udpEnabled,
190 + RequireTCP: e.tcpEnabled,
191 }
181 - e.listenerMu.RUnlock()
182 - sort.Strings(state.ActiveRelayURLs)
183 - return state
192 }
193
194 func (e *Exposure) Addr() net.Addr {
@@ -346,6 +354,7 @@ func (e *Exposure) Close() error {
354 e.listenerMu.Lock()
355 relayListeners := e.relayListeners
356 e.relayListeners = make(map[string]*Listener)
357 + e.activeRelayURLs = nil
358 e.listenerMu.Unlock()
359
360 relayURLs := make([]string, 0, len(relayListeners))
@@ -371,32 +380,33 @@ func (e *Exposure) Close() error {
380 }
381
382 func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
374 - selectedRelays := e.relaySet.PriorityRelays(e.clientState())
375 - desiredRelayURLs := make(map[string]struct{}, len(selectedRelays))
376 - for _, state := range selectedRelays {
377 - desiredRelayURLs[state.Descriptor.APIHTTPSAddr] = struct{}{}
378 - }
383 + desiredRelayURLs := e.relaySet.PriorityRelays(e.clientState())
384
385 e.listenerMu.Lock()
386 staleRelayListeners := make(map[string]*Listener)
387 + removedRelayURLs := make([]string, 0)
388 for relayURL, listener := range e.relayListeners {
383 - if _, ok := desiredRelayURLs[relayURL]; ok {
389 + if slices.Contains(desiredRelayURLs, relayURL) {
390 continue
391 }
392 staleRelayListeners[relayURL] = listener
393 + removedRelayURLs = append(removedRelayURLs, relayURL)
394 delete(e.relayListeners, relayURL)
395 }
396
390 - missingRelayURLs := make([]string, 0, len(selectedRelays))
391 - for _, state := range selectedRelays {
392 - relayURL := state.Descriptor.APIHTTPSAddr
397 + missingRelayURLs := make([]string, 0, len(desiredRelayURLs))
398 + for _, relayURL := range desiredRelayURLs {
399 if _, ok := e.relayListeners[relayURL]; ok {
400 continue
401 }
402 missingRelayURLs = append(missingRelayURLs, relayURL)
403 }
404 e.listenerMu.Unlock()
405 + if len(removedRelayURLs) > 1 {
406 + slices.Sort(removedRelayURLs)
407 + }
408
409 + addedRelayURLs := make([]string, 0, len(missingRelayURLs))
410 for relayURL, listener := range staleRelayListeners {
411 if listener == nil {
412 continue
@@ -406,11 +416,16 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
416 }
417 }
418 for _, relayURL := range missingRelayURLs {
419 + retryCount := 10
420 + if slices.Contains(e.explicitRelays, relayURL) {
421 + retryCount = 0
422 + }
423 listener, err := NewListener(context.Background(), relayURL, ListenerConfig{
424 Identity: e.identity.Copy(),
425 UDPEnabled: e.udpEnabled,
426 TCPEnabled: e.tcpEnabled,
427 BanMITM: e.banMITM,
428 + RetryCount: retryCount,
429 Metadata: e.metadata.Copy(),
430 RootCAPEM: append([]byte(nil), e.rootCAPEM...),
431 relaySet: e.relaySet,
@@ -438,9 +453,27 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
453 }
454 e.relayListeners[relayURL] = listener
455 e.listenerMu.Unlock()
456 + addedRelayURLs = append(addedRelayURLs, relayURL)
457
458 go e.runListenerAcceptLoop(listener)
459 }
460 +
461 + e.listenerMu.Lock()
462 + e.activeRelayURLs = e.activeRelayURLs[:0]
463 + for _, relayURL := range desiredRelayURLs {
464 + if _, ok := e.relayListeners[relayURL]; !ok {
465 + continue
466 + }
467 + e.activeRelayURLs = append(e.activeRelayURLs, relayURL)
468 + }
469 + e.listenerMu.Unlock()
470 + if len(removedRelayURLs) > 0 || len(addedRelayURLs) > 0 {
471 + log.Info().
472 + Strs("added_relays", addedRelayURLs).
473 + Strs("removed_relays", removedRelayURLs).
474 + Strs("desired_relays", desiredRelayURLs).
475 + Msg("reconciled relay listeners")
476 + }
477 return nil
478 }
479
@@ -484,6 +517,9 @@ func (e *Exposure) runListenerAcceptLoop(listener *Listener) {
517 if current, ok := e.relayListeners[relayURL]; ok && current == listener {
518 delete(e.relayListeners, relayURL)
519 }
520 + if index := slices.Index(e.activeRelayURLs, relayURL); index >= 0 {
521 + e.activeRelayURLs = slices.Delete(e.activeRelayURLs, index, index+1)
522 + }
523 e.listenerMu.Unlock()
524 }()
525
sdk/expose_test.go
+36 -63
@@ -3,11 +3,9 @@ package sdk
3 import (
4 "net/url"
5 "testing"
6 - "time"
6
7 "github.com/gosuda/portal-tunnel/v2/portal/discovery"
8 "github.com/gosuda/portal-tunnel/v2/types"
10 - "github.com/gosuda/portal-tunnel/v2/utils"
9 )
10
11 func mustRelaySet(t *testing.T, relayURLs ...string) *discovery.RelaySet {
@@ -20,33 +18,7 @@ func mustRelaySet(t *testing.T, relayURLs ...string) *discovery.RelaySet {
18 return set
19 }
20
23 -func mustRelayDescriptor(t *testing.T, relayName, relayURL string) types.RelayDescriptor {
24 - t.Helper()
25 -
26 - now := time.Now().UTC()
27 - desc, err := utils.NormalizeDescriptor(types.RelayDescriptor{
28 - Identity: types.Identity{
29 - Name: relayName,
30 - },
31 - RelayID: relayURL,
32 - Version: 1,
33 - IssuedAt: now,
34 - ExpiresAt: now.Add(time.Hour),
35 - APIHTTPSAddr: relayURL,
36 - })
37 - if err != nil {
38 - t.Fatalf("NormalizeDescriptor() error = %v", err)
39 - }
40 - return desc
41 -}
42 -
43 -func applyRelayDiscovery(t *testing.T, set *discovery.RelaySet, identity types.Identity, targetURL string, resp types.DiscoveryResponse, now time.Time) error {
44 - t.Helper()
45 - _, err := set.ApplyRelayDiscoveryResponse(identity, targetURL, resp, now)
46 - return err
47 -}
48 -
49 -func TestExposureBanRelayURLMovesRelay(t *testing.T) {
21 +func TestExposureReconcileRemovesBannedRelayFromActiveSet(t *testing.T) {
22 const (
23 relayA = "https://relay-a.example"
24 relayB = "https://relay-b.example"
@@ -56,42 +28,52 @@ func TestExposureBanRelayURLMovesRelay(t *testing.T) {
28 if err != nil {
29 t.Fatalf("url.Parse() error = %v", err)
30 }
59 -
60 - listener := &Listener{
61 - api: &apiClient{baseURL: relayURL},
31 + relayBURL, err := url.Parse(relayB)
32 + if err != nil {
33 + t.Fatalf("url.Parse() error = %v", err)
34 }
35
36 exposure := &Exposure{
65 - relaySet: mustRelaySet(t, relayA, relayB),
66 - relayListeners: make(map[string]*Listener, 2),
37 + relaySet: mustRelaySet(t, relayA, relayB),
38 + relayListeners: make(map[string]*Listener, 2),
39 + activeRelayURLs: []string{relayA, relayB},
40 }
41 + relayAClosed := make(chan struct{})
42 exposure.relayListeners = map[string]*Listener{
69 - relayA: listener,
70 - relayB: {},
43 + relayA: {
44 + api: &apiClient{baseURL: relayURL},
45 + cancel: func() { close(relayAClosed) },
46 + doneCh: relayAClosed,
47 + },
48 + relayB: {
49 + api: &apiClient{baseURL: relayBURL},
50 + },
51 }
52
53 exposure.relaySet.BanRelayURL(relayA)
74 - exposure.listenerMu.Lock()
75 - delete(exposure.relayListeners, relayA)
76 - exposure.listenerMu.Unlock()
54 + if err := exposure.reconcileRelayListeners(false); err != nil {
55 + t.Fatalf("reconcileRelayListeners() error = %v", err)
56 + }
57 +
58 + select {
59 + case <-relayAClosed:
60 + default:
61 + t.Fatal("banned relay listener was not closed")
62 + }
63
64 if got := exposure.ActiveRelayURLs(); len(got) != 1 || got[0] != relayB {
65 t.Fatalf("ActiveRelayURLs() = %v, want [%q]", got, relayB)
66 }
67
82 - knownRelayURLs := exposure.ActiveRelayURLs()
68 exposure.listenerMu.RLock()
69 _, listenerExists := exposure.relayListeners[relayA]
70 exposure.listenerMu.RUnlock()
86 - if len(knownRelayURLs) != 1 || knownRelayURLs[0] != relayB {
87 - t.Fatalf("knownRelayURLs = %v, want [%q]", knownRelayURLs, relayB)
88 - }
71 if listenerExists {
72 t.Fatal("banned relay listener still exists in exposure.listeners")
73 }
74 }
75
94 -func TestExposureSetRelayURLsSkipsBannedRelay(t *testing.T) {
76 +func TestExposureReconcileSkipsBannedRelay(t *testing.T) {
77 const (
78 relayA = "https://relay-a.example"
79 relayB = "https://relay-b.example"
@@ -115,13 +97,19 @@ func TestExposureSetRelayURLsSkipsBannedRelay(t *testing.T) {
97 if got := exposure.ActiveRelayURLs(); len(got) != 1 || got[0] != relayA {
98 t.Fatalf("ActiveRelayURLs() = %v, want [%q]", got, relayA)
99 }
118 - knownRelayURLs := exposure.ActiveRelayURLs()
119 - if len(knownRelayURLs) != 1 || knownRelayURLs[0] != relayA {
120 - t.Fatalf("knownRelayURLs = %v, want [%q]", knownRelayURLs, relayA)
100 + exposure.listenerMu.RLock()
101 + _, relayAExists := exposure.relayListeners[relayA]
102 + _, relayBExists := exposure.relayListeners[relayB]
103 + exposure.listenerMu.RUnlock()
104 + if !relayAExists {
105 + t.Fatal("active relay listener missing from exposure.listeners")
106 + }
107 + if relayBExists {
108 + t.Fatal("banned relay listener should not be added to exposure.listeners")
109 }
110 }
111
124 -func TestExposureSetRelayURLsRemovesStaleListener(t *testing.T) {
112 +func TestExposureReconcileRemovesStaleListener(t *testing.T) {
113 const (
114 relayA = "https://relay-a.example"
115 relayB = "https://relay-b.example"
@@ -180,18 +168,3 @@ func TestExposureSetRelayURLsRemovesStaleListener(t *testing.T) {
168 t.Fatal("active relay listener missing from exposure.listeners")
169 }
170 }
183 -
184 -func TestExposurePinDiscoveredDescriptorAllowsURLChangeForSameIdentity(t *testing.T) {
185 - exposure := &Exposure{relaySet: mustRelaySet(t)}
186 - desc := mustRelayDescriptor(t, "relay-a", "https://relay-a.example")
187 -
188 - if err := applyRelayDiscovery(t, exposure.relaySet, desc.Identity, desc.APIHTTPSAddr, types.DiscoveryResponse{ProtocolVersion: types.ProtocolVersion, Self: desc}, time.Now().UTC()); err != nil {
189 - t.Fatalf("ApplyRelayDiscoveryResponse() error = %v", err)
190 - }
191 -
192 - changedURL := mustRelayDescriptor(t, desc.Name, "https://relay-b.example")
193 - err := applyRelayDiscovery(t, exposure.relaySet, desc.Identity, "", types.DiscoveryResponse{ProtocolVersion: types.ProtocolVersion, Self: changedURL}, time.Now().UTC())
194 - if err != nil {
195 - t.Fatalf("ApplyRelayDiscoveryResponse() error = %v, want nil for same relay identity", err)
196 - }
197 -}
sdk/sdk_test.go deleted
-427
@@ -1,427 +0,0 @@
1 -package sdk
2 -
3 -import (
4 - "context"
5 - "encoding/json"
6 - "net/http"
7 - "net/http/httptest"
8 - "strings"
9 - "sync"
10 - "testing"
11 - "time"
12 -
13 - "github.com/spruceid/siwe-go"
14 -
15 - "github.com/gosuda/portal-tunnel/v2/types"
16 - "github.com/gosuda/portal-tunnel/v2/utils"
17 -)
18 -
19 -func TestExposeNoRelayInputs(t *testing.T) {
20 - exposure, err := Expose(context.Background(), ExposeConfig{Name: "demo"})
21 - if err != nil {
22 - t.Fatalf("Expose() error = %v", err)
23 - }
24 - if exposure == nil {
25 - t.Fatal("Expose() exposure = nil, want non-nil")
26 - }
27 - defer exposure.Close()
28 - if got := exposure.ActiveRelayURLs(); len(got) != 0 {
29 - t.Fatalf("Expose() relay urls = %v, want empty", got)
30 - }
31 -}
32 -
33 -func TestExposeLoadsPrivateKeyFromIdentityPath(t *testing.T) {
34 - privateKey := strings.Repeat("11", 32)
35 - identity, err := utils.ResolveSecp256k1Identity(privateKey)
36 - if err != nil {
37 - t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
38 - }
39 - identity.Name = "demo"
40 - identityPath := t.TempDir() + "/identity.json"
41 - if err := utils.SaveIdentity(identityPath, identity); err != nil {
42 - t.Fatalf("SaveIdentity() error = %v", err)
43 - }
44 -
45 - challengeReqCh := make(chan types.RegisterChallengeRequest, 1)
46 - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47 - switch r.URL.Path {
48 - case types.PathSDKDomain:
49 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
50 - OK: true,
51 - Data: types.DomainResponse{
52 - ProtocolVersion: types.ProtocolVersion,
53 - },
54 - })
55 - case types.PathSDKRegisterChallenge:
56 - var challengeReq types.RegisterChallengeRequest
57 - if err := json.NewDecoder(r.Body).Decode(&challengeReq); err != nil {
58 - t.Fatalf("decode register challenge request: %v", err)
59 - }
60 - select {
61 - case challengeReqCh <- challengeReq:
62 - default:
63 - }
64 - writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterChallengeResponse]{
65 - OK: true,
66 - Data: types.RegisterChallengeResponse{
67 - ChallengeID: "challenge-1",
68 - ExpiresAt: time.Now().Add(time.Minute).UTC(),
69 - SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.Identity.Address, "challenge-1"),
70 - },
71 - })
72 - case types.PathSDKRegister:
73 - var registerReq types.RegisterRequest
74 - if err := json.NewDecoder(r.Body).Decode(&registerReq); err != nil {
75 - t.Fatalf("decode register request: %v", err)
76 - }
77 - writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
78 - OK: true,
79 - Data: types.RegisterResponse{
80 - Identity: types.Identity{Name: identity.Name, Address: identity.Address},
81 - Hostname: "127.0.0.1",
82 - AccessToken: "jwt-register-2",
83 - },
84 - })
85 - case types.PathSDKConnect:
86 - writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
87 - OK: false,
88 - Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "not used in test"},
89 - })
90 - case types.PathSDKRenew:
91 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
92 - OK: true,
93 - Data: types.RenewResponse{AccessToken: "jwt-renew-2"},
94 - })
95 - case types.PathSDKUnregister:
96 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
97 - default:
98 - http.NotFound(w, r)
99 - }
100 - }))
101 - defer server.Close()
102 -
103 - exposure, err := Expose(context.Background(), ExposeConfig{
104 - RelayURLs: []string{server.URL},
105 - IdentityPath: identityPath,
106 - })
107 - if err != nil {
108 - t.Fatalf("Expose() error = %v", err)
109 - }
110 - defer exposure.Close()
111 -
112 - var challengeReq types.RegisterChallengeRequest
113 - waitForSDKTest(t, func() bool {
114 - select {
115 - case challengeReq = <-challengeReqCh:
116 - return true
117 - default:
118 - return false
119 - }
120 - })
121 -
122 - if challengeReq.Identity.Address != identity.Address {
123 - t.Fatalf("register challenge Identity.Address = %q, want %q", challengeReq.Identity.Address, identity.Address)
124 - }
125 - if challengeReq.Identity.Name != identity.Name {
126 - t.Fatalf("register challenge Identity.Name = %q, want %q", challengeReq.Identity.Name, identity.Name)
127 - }
128 -}
129 -
130 -func TestExposeLoadsIdentityFromJSON(t *testing.T) {
131 - privateKey := strings.Repeat("22", 32)
132 - identity, err := utils.ResolveSecp256k1Identity(privateKey)
133 - if err != nil {
134 - t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
135 - }
136 - identity.Name = "demo-json"
137 -
138 - payload, err := json.Marshal(map[string]string{
139 - "name": identity.Name,
140 - "address": identity.Address,
141 - "public_key": identity.PublicKey,
142 - "private_key": identity.PrivateKey,
143 - })
144 - if err != nil {
145 - t.Fatalf("json.Marshal() error = %v", err)
146 - }
147 -
148 - challengeReqCh := make(chan types.RegisterChallengeRequest, 1)
149 - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
150 - switch r.URL.Path {
151 - case types.PathSDKDomain:
152 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
153 - OK: true,
154 - Data: types.DomainResponse{
155 - ProtocolVersion: types.ProtocolVersion,
156 - },
157 - })
158 - case types.PathSDKRegisterChallenge:
159 - var challengeReq types.RegisterChallengeRequest
160 - if err := json.NewDecoder(r.Body).Decode(&challengeReq); err != nil {
161 - t.Fatalf("decode register challenge request: %v", err)
162 - }
163 - select {
164 - case challengeReqCh <- challengeReq:
165 - default:
166 - }
167 - writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterChallengeResponse]{
168 - OK: true,
169 - Data: types.RegisterChallengeResponse{
170 - ChallengeID: "challenge-1",
171 - ExpiresAt: time.Now().Add(time.Minute).UTC(),
172 - SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.Identity.Address, "challenge-1"),
173 - },
174 - })
175 - case types.PathSDKRegister:
176 - writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
177 - OK: true,
178 - Data: types.RegisterResponse{
179 - Identity: types.Identity{Name: identity.Name, Address: identity.Address},
180 - Hostname: "127.0.0.1",
181 - AccessToken: "jwt-register-json",
182 - },
183 - })
184 - case types.PathSDKConnect:
185 - writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
186 - OK: false,
187 - Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "not used in test"},
188 - })
189 - case types.PathSDKRenew:
190 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
191 - OK: true,
192 - Data: types.RenewResponse{AccessToken: "jwt-renew-json"},
193 - })
194 - case types.PathSDKUnregister:
195 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
196 - default:
197 - http.NotFound(w, r)
198 - }
199 - }))
200 - defer server.Close()
201 -
202 - exposure, err := Expose(context.Background(), ExposeConfig{
203 - RelayURLs: []string{server.URL},
204 - IdentityJSON: string(payload),
205 - })
206 - if err != nil {
207 - t.Fatalf("Expose() error = %v", err)
208 - }
209 - defer exposure.Close()
210 -
211 - var challengeReq types.RegisterChallengeRequest
212 - waitForSDKTest(t, func() bool {
213 - select {
214 - case challengeReq = <-challengeReqCh:
215 - return true
216 - default:
217 - return false
218 - }
219 - })
220 -
221 - if challengeReq.Identity.Name != identity.Name {
222 - t.Fatalf("register challenge Identity.Name = %q, want %q", challengeReq.Identity.Name, identity.Name)
223 - }
224 - if challengeReq.Identity.Address != identity.Address {
225 - t.Fatalf("register challenge Identity.Address = %q, want %q", challengeReq.Identity.Address, identity.Address)
226 - }
227 -}
228 -
229 -func TestExposeGeneratesAddressWithoutPrivateKey(t *testing.T) {
230 - challengeReqCh := make(chan types.RegisterChallengeRequest, 1)
231 - var mu sync.RWMutex
232 - var registeredIdentity types.Identity
233 - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
234 - switch r.URL.Path {
235 - case types.PathSDKDomain:
236 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
237 - OK: true,
238 - Data: types.DomainResponse{
239 - ProtocolVersion: types.ProtocolVersion,
240 - },
241 - })
242 - case types.PathSDKRegisterChallenge:
243 - var challengeReq types.RegisterChallengeRequest
244 - if err := json.NewDecoder(r.Body).Decode(&challengeReq); err != nil {
245 - t.Fatalf("decode register challenge request: %v", err)
246 - }
247 - mu.Lock()
248 - registeredIdentity = challengeReq.Identity.Copy()
249 - mu.Unlock()
250 - select {
251 - case challengeReqCh <- challengeReq:
252 - default:
253 - }
254 - writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterChallengeResponse]{
255 - OK: true,
256 - Data: types.RegisterChallengeResponse{
257 - ChallengeID: "challenge-1",
258 - ExpiresAt: time.Now().Add(time.Minute).UTC(),
259 - SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.Identity.Address, "challenge-1"),
260 - },
261 - })
262 - case types.PathSDKRegister:
263 - var registerReq types.RegisterRequest
264 - if err := json.NewDecoder(r.Body).Decode(&registerReq); err != nil {
265 - t.Fatalf("decode register request: %v", err)
266 - }
267 - mu.RLock()
268 - identity := registeredIdentity.Copy()
269 - mu.RUnlock()
270 - writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
271 - OK: true,
272 - Data: types.RegisterResponse{
273 - Identity: identity,
274 - Hostname: "127.0.0.1",
275 - AccessToken: "jwt-register-3",
276 - },
277 - })
278 - case types.PathSDKConnect:
279 - writeSDKTestEnvelope(w, http.StatusForbidden, types.APIEnvelope[any]{
280 - OK: false,
281 - Error: &types.APIError{Code: types.APIErrorCodeUnauthorized, Message: "not used in test"},
282 - })
283 - case types.PathSDKRenew:
284 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.RenewResponse]{
285 - OK: true,
286 - Data: types.RenewResponse{AccessToken: "jwt-renew-3"},
287 - })
288 - case types.PathSDKUnregister:
289 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[any]{OK: true})
290 - default:
291 - http.NotFound(w, r)
292 - }
293 - }))
294 - defer server.Close()
295 -
296 - exposure, err := Expose(context.Background(), ExposeConfig{
297 - RelayURLs: []string{server.URL},
298 - Name: "demo",
299 - })
300 - if err != nil {
301 - t.Fatalf("Expose() error = %v", err)
302 - }
303 - defer exposure.Close()
304 -
305 - var challengeReq types.RegisterChallengeRequest
306 - waitForSDKTest(t, func() bool {
307 - select {
308 - case challengeReq = <-challengeReqCh:
309 - return true
310 - default:
311 - return false
312 - }
313 - })
314 -
315 - if challengeReq.Identity.Address == "" {
316 - t.Fatal("register challenge Identity.Address = empty, want generated address")
317 - }
318 - if _, err := utils.NormalizeEVMAddress(challengeReq.Identity.Address); err != nil {
319 - t.Fatalf("register challenge Identity.Address = %q, want valid EVM address: %v", challengeReq.Identity.Address, err)
320 - }
321 -}
322 -
323 -func TestAPIClientRegisterLeaseRequiresSNIPortForUDP(t *testing.T) {
324 - privateKey := strings.Repeat("33", 32)
325 - identity, err := utils.ResolveSecp256k1Identity(privateKey)
326 - if err != nil {
327 - t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
328 - }
329 - identity.Name = "demo-udp"
330 -
331 - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
332 - switch r.URL.Path {
333 - case types.PathSDKDomain:
334 - writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
335 - OK: true,
336 - Data: types.DomainResponse{
337 - ProtocolVersion: types.ProtocolVersion,
338 - },
339 - })
340 - case types.PathSDKRegisterChallenge:
341 - var challengeReq types.RegisterChallengeRequest
342 - if err := json.NewDecoder(r.Body).Decode(&challengeReq); err != nil {
343 - t.Fatalf("decode register challenge request: %v", err)
344 - }
345 - writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterChallengeResponse]{
346 - OK: true,
347 - Data: types.RegisterChallengeResponse{
348 - ChallengeID: "challenge-udp",
349 - ExpiresAt: time.Now().Add(time.Minute).UTC(),
350 - SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.Identity.Address, "challenge-udp"),
351 - },
352 - })
353 - case types.PathSDKRegister:
354 - writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
355 - OK: true,
356 - Data: types.RegisterResponse{
357 - Identity: types.Identity{Name: identity.Name, Address: identity.Address},
358 - Hostname: "127.0.0.1",
359 - AccessToken: "jwt-register-udp",
360 - UDPEnabled: true,
361 - },
362 - })
363 - default:
364 - http.NotFound(w, r)
365 - }
366 - }))
367 - defer server.Close()
368 -
369 - api, err := newApiClient(server.URL, ListenerConfig{Identity: identity})
370 - if err != nil {
371 - t.Fatalf("newApiClient() error = %v", err)
372 - }
373 -
374 - _, err = api.registerLease(context.Background(), 30*time.Second, true, false)
375 - if err == nil {
376 - t.Fatal("registerLease() error = nil, want missing sni port error")
377 - }
378 - if !strings.Contains(err.Error(), "sni port") {
379 - t.Fatalf("registerLease() error = %v, want missing sni port error", err)
380 - }
381 -}
382 -
383 -func mustSDKTestSIWEMessage(t *testing.T, r *http.Request, address, challengeID string) string {
384 - t.Helper()
385 -
386 - scheme := "https"
387 - if r.TLS == nil {
388 - scheme = "http"
389 - }
390 - message, err := siwe.InitMessage(r.Host, address, scheme+"://"+r.Host+types.PathSDKRegister, "testnonce123", map[string]interface{}{
391 - "statement": "Register a portal lease",
392 - "chainId": 1,
393 - "issuedAt": time.Now().UTC().Format(time.RFC3339),
394 - "expirationTime": time.Now().Add(time.Minute).UTC().Format(time.RFC3339),
395 - "requestId": challengeID,
396 - })
397 - if err != nil {
398 - t.Fatalf("siwe.InitMessage() error = %v", err)
399 - }
400 - return message.String()
401 -}
402 -
403 -func writeSDKTestEnvelope[T any](w http.ResponseWriter, status int, envelope types.APIEnvelope[T]) {
404 - w.Header().Set("Content-Type", "application/json")
405 - w.WriteHeader(status)
406 - _ = json.NewEncoder(w).Encode(envelope)
407 -}
408 -
409 -func waitForSDKTest(t *testing.T, fn func() bool) {
410 - t.Helper()
411 -
412 - waitForSDKTestWithTimeout(t, 5*time.Second, fn)
413 -}
414 -
415 -func waitForSDKTestWithTimeout(t *testing.T, timeout time.Duration, fn func() bool) {
416 - t.Helper()
417 -
418 - deadline := time.Now().Add(timeout)
419 - for time.Now().Before(deadline) {
420 - if fn() {
421 - return
422 - }
423 - time.Sleep(10 * time.Millisecond)
424 - }
425 -
426 - t.Fatal("timed out waiting for condition")
427 -}