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(®isterReq); 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(®isterReq); 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
-}