Refactor relay descriptor and related tests for overlay support
Kim committed
Apr 17, 2026 at 11:16 UTC
75312c238e665499836223856de5c3c6419330f0
19 files changed
+221
-358
docs/src/routes/api-reference/+page.md
+22
@@ -141,6 +141,28 @@ Returns signed relay discovery descriptors for this relay and any known peer rel
141
| `generated_at` | `string` | ISO 8601 timestamp |
142
| `relays` | `RelayDescriptor[]` | Signed descriptors for this relay and known peer relays |
143
144
+`RelayDescriptor` contains the signed relay contract and relay-reported telemetry:
145
+
146
+| Field | Type | Description |
147
+|-------|------|-------------|
148
+| `address` | `string` | Relay signing address used to verify `signature` |
149
+| `version` | `string` | Discovery protocol version used by this signed descriptor |
150
+| `issued_at` | `string` | Descriptor issue time |
151
+| `expires_at` | `string` | Descriptor expiry time |
152
+| `api_https_addr` | `string` | Public HTTPS API base URL |
153
+| `wireguard_public_key` | `string` | WireGuard overlay public key, present when overlay is enabled |
154
+| `wireguard_port` | `number` | Public WireGuard UDP port on the `api_https_addr` host, present when overlay is enabled |
155
+| `supports_overlay` | `boolean` | Relay can participate in WireGuard multi-hop overlay routing |
156
+| `supports_udp` | `boolean` | Relay can allocate public UDP leases |
157
+| `supports_tcp` | `boolean` | Relay can allocate raw TCP port leases |
158
+| `active_connections` | `number` | Current proxied connection count reported by the relay |
159
+| `tcp_bps` | `number` | Recent proxied TCP throughput in bytes per second |
160
+| `signature` | `string` | Signature over the descriptor fields above |
161
+
162
+Relay telemetry is sampled when the descriptor is issued; use `issued_at` to judge freshness.
163
+
164
+Overlay peer support is advertised by `supports_overlay`. When it is true, `wireguard_public_key` and `wireguard_port` are present. The WireGuard endpoint host is the `api_https_addr` host, and the overlay IPv4 is derived from the WireGuard public key. Relay-local observations such as recent overlay reachability are not part of the signed descriptor.
165
+
166
**Example:**
167
168
```bash
docs/src/routes/architecture/+page.md
+4
-4
@@ -174,7 +174,7 @@ UDP client
174
- `/sdk/register` is authenticated by a SIWE challenge/response flow using the SDK identity secp256k1 key. On success, the relay issues a lease-scoped ES256K JWT access token signed by the relay identity key and used for the rest of the lease lifecycle.
175
- Relay URLs must use `https://`.
176
- HTTP/2 stays disabled on the admin/API TLS listener because `/sdk/connect` depends on HTTP/1.1 hijacking semantics.
177
-- WireGuard, when enabled, is relay-to-relay overlay transport only. It is not used for tenant stream TLS, public UDP ingress, or `/sdk/*` control-plane traffic.
177
+- WireGuard, when enabled, is relay-to-relay overlay transport only. It carries multi-hop relay forwarding and overlay discovery, but it is not used for direct tenant TLS termination, public UDP ingress, or `/sdk/*` control-plane traffic.
178
179
### Reverse Session Protocol
180
@@ -295,10 +295,10 @@ Result: raw public UDP exposure with an internal QUIC datagram backhaul. UDP and
295
296
- Discovery bootstraps from public HTTPS relay URLs, then expands through relay-to-relay `/discovery` polling and periodic self-announces to bootstrap relays through `/discovery/announce`.
297
- SDK exposures consume relay discovery results to choose relays, but they do not announce themselves and do not serve `/discovery`.
298
-- Discovery descriptors carry secp256k1 signatures that bind relay routing metadata to the relay identity. Lease access tokens remain separate and authorize tenant lease operations only.
298
+- Discovery descriptors are signed relay self-descriptions. They bind relay routing metadata such as `api_https_addr`, `supports_overlay`, `wireguard_public_key`, and `wireguard_port` to the relay identity. Lease access tokens remain separate and authorize tenant lease operations only.
299
- `/discovery/announce` accepts only signed relay descriptors. Loopback or localhost relay descriptors are rejected because they cannot join the public discovery mesh.
300
- The overlay peer API is plain HTTP on the WireGuard network, not public Internet HTTP. It serves the same discovery payload shape used by public `/discovery`.
301
-- Overlay failure affects inter-relay discovery and mesh synchronization only. Tenant stream routing, keyless TLS, register/renew/connect, and public UDP ingress do not depend on the WireGuard transport path.
301
+- Overlay failure affects inter-relay discovery, mesh synchronization, and multi-hop relay forwarding. Direct tenant TLS routing, keyless TLS, register/renew/connect, and public UDP ingress do not depend on the WireGuard transport path.
302
303
## Control Plane Flow
304
@@ -358,7 +358,7 @@ The relay signs handshake digests via `/v1/sign` but never receives tenant TLS t
358
- One canonical raw TCP reverse transport
359
- Dedicated TCP port allocation for non-TLS services with raw TCP bridging
360
- Raw public UDP exposure with an internal QUIC datagram backhaul
361
-- Optional WireGuard relay overlay for relay discovery and peer synchronization
361
+- Optional WireGuard relay overlay for relay discovery, peer synchronization, and multi-hop relay forwarding
362
- SNI-based routing with root-host fallback
363
- End-to-end tenant TLS with relay-backed keyless signing
364
- Traffic-triggered detect-only MITM self-probing for probable relay-side TLS termination
portal/api_server.go
+16
-34
@@ -166,43 +166,28 @@ func (s *Server) signedRelayDescriptor(now time.Time) (types.RelayDescriptor, er
166
} else {
167
now = now.UTC()
168
}
169
- activeConns := float64(s.proxy.activeConnectionCount())
170
- tcpTrafficBPS := s.proxy.currentTCPBPS(now)
171
- ingressAddr := s.identity.Name
172
- if s.cfg.SNIPort != 0 && s.cfg.SNIPort != 443 {
173
- ingressAddr = fmt.Sprintf("%s:%d", ingressAddr, s.cfg.SNIPort)
174
- }
169
176
- var wireGuardPublicKey, wireGuardEndpoint, overlayIPv4 string
177
- var overlayCIDRs []string
170
+ var wireGuardPublicKey string
171
+ var wireGuardPort int
172
if s.overlay != nil {
173
cfg := s.overlay.Config()
174
wireGuardPublicKey = cfg.PublicKey
181
- wireGuardEndpoint = cfg.Endpoint
182
- overlayIPv4 = cfg.OverlayIPv4
183
- overlayCIDRs = append([]string(nil), cfg.OverlayCIDRs...)
175
+ wireGuardPort = cfg.ListenPort
176
}
177
178
self := types.RelayDescriptor{
187
- Identity: s.identity.Base(),
188
- RelayID: s.cfg.PortalURL,
189
- OwnerAddress: s.identity.Address,
190
- Version: 1,
191
- IssuedAt: now,
192
- ExpiresAt: now.Add(discovery.DiscoveryDescriptorTTL),
193
- APIHTTPSAddr: s.cfg.PortalURL,
194
- Discovery: s.cfg.DiscoveryEnabled,
195
- IngressTLSAddr: ingressAddr,
196
- WireGuardPublicKey: wireGuardPublicKey,
197
- WireGuardEndpoint: wireGuardEndpoint,
198
- OverlayIPv4: overlayIPv4,
199
- OverlayCIDRs: overlayCIDRs,
200
- SupportsUDP: s.cfg.UDPEnabled && s.quicTunnel != nil,
201
- SupportsTCP: s.cfg.TCPEnabled,
202
- SupportsOverlayPeer: s.overlay != nil,
203
- Load: activeConns,
204
- LoadScore: tcpTrafficBPS,
205
- LastUpdated: now.UnixMilli(),
179
+ Address: s.identity.Address,
180
+ Version: types.DiscoveryVersion,
181
+ IssuedAt: now,
182
+ ExpiresAt: now.Add(discovery.DiscoveryDescriptorTTL),
183
+ APIHTTPSAddr: s.cfg.PortalURL,
184
+ WireGuardPublicKey: wireGuardPublicKey,
185
+ WireGuardPort: wireGuardPort,
186
+ SupportsOverlay: s.overlay != nil,
187
+ SupportsUDP: s.cfg.UDPEnabled && s.quicTunnel != nil,
188
+ SupportsTCP: s.cfg.TCPEnabled,
189
+ ActiveConnections: s.proxy.activeConnectionCount(),
190
+ TCPBPS: s.proxy.currentTCPBPS(now),
191
}
192
193
signedSelf, err := auth.SignRelayDescriptor(self, s.identity.PrivateKey)
@@ -535,10 +520,7 @@ func (s *Server) handleHop(w http.ResponseWriter, r *http.Request) {
520
utils.InvalidRequestError(fmt.Errorf("forward relay: %w", err)).Write(w)
521
return
522
}
538
- if !forwardRelay.SupportsOverlayPeer ||
539
- strings.TrimSpace(forwardRelay.WireGuardPublicKey) == "" ||
540
- strings.TrimSpace(forwardRelay.WireGuardEndpoint) == "" ||
541
- strings.TrimSpace(forwardRelay.OverlayIPv4) == "" {
523
+ if !forwardRelay.HasOverlayPeer() {
524
utils.InvalidRequestError(errors.New("forward relay wireguard overlay metadata is required")).Write(w)
525
return
526
}
portal/auth/relay_descriptor.go
-10
@@ -16,10 +16,6 @@ import (
16
// private key (hex encoded). The signature is recoverable, so verifiers do
17
// not need to know the public key out of band; they recover it from the
18
// signature and check it derives the descriptor's Address field.
19
-//
20
-// Mutable telemetry fields (Load, LoadScore, LastUpdated) are NOT covered by
21
-// the signature, so callers may update them after signing without
22
-// invalidating the signature.
19
func SignRelayDescriptor(desc types.RelayDescriptor, privateKeyHex string) (types.RelayDescriptor, error) {
20
privateKey, _, err := utils.ParseSecp256k1PrivateKeyHex(privateKeyHex, true)
21
if err != nil {
@@ -31,9 +27,6 @@ func SignRelayDescriptor(desc types.RelayDescriptor, privateKeyHex string) (type
27
if err != nil {
28
return types.RelayDescriptor{}, fmt.Errorf("normalize relay descriptor for signing: %w", err)
29
}
34
- if strings.TrimSpace(normalized.Address) == "" {
35
- return types.RelayDescriptor{}, errors.New("relay descriptor address is required for signature verification")
36
- }
30
desc = normalized
31
32
canonical, err := types.CanonicalBytes(desc)
@@ -70,9 +63,6 @@ func VerifyRelayDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, e
63
if err != nil {
64
return types.RelayDescriptor{}, fmt.Errorf("relay descriptor signature is invalid: normalize: %w", err)
65
}
73
- if strings.TrimSpace(normalized.Address) == "" {
74
- return types.RelayDescriptor{}, errors.New("relay descriptor address is required for signature verification")
75
- }
66
canonical, err := types.CanonicalBytes(normalized)
67
if err != nil {
68
return types.RelayDescriptor{}, fmt.Errorf("canonicalize relay descriptor: %w", err)
portal/discovery/announce_test.go
+14
-24
@@ -18,36 +18,26 @@ func mustSigningIdentity(t *testing.T) types.Identity {
18
return identity
19
}
20
21
-func mustUnsignedDescriptor(t *testing.T, signing types.Identity, relayName, relayURL string) types.RelayDescriptor {
21
+func mustUnsignedDescriptor(t *testing.T, signing types.Identity, relayURL string) types.RelayDescriptor {
22
t.Helper()
23
now := time.Now().UTC().Truncate(time.Microsecond)
24
return types.RelayDescriptor{
25
- Identity: types.Identity{
26
- Name: relayName,
27
- Address: signing.Address,
28
- },
29
- RelayID: relayURL,
30
- Version: 1,
25
+ Address: signing.Address,
26
+ Version: types.DiscoveryVersion,
27
IssuedAt: now,
28
ExpiresAt: now.Add(time.Hour),
29
APIHTTPSAddr: relayURL,
34
- Discovery: true,
30
}
31
}
32
38
-func mustSignedDescriptor(t *testing.T, signing types.Identity, relayName, relayURL string, issuedAt time.Time) types.RelayDescriptor {
33
+func mustSignedDescriptor(t *testing.T, signing types.Identity, relayURL string, issuedAt time.Time) types.RelayDescriptor {
34
t.Helper()
35
signed, err := auth.SignRelayDescriptor(types.RelayDescriptor{
41
- Identity: types.Identity{
42
- Name: relayName,
43
- Address: signing.Address,
44
- },
45
- RelayID: relayURL,
46
- Version: 1,
36
+ Address: signing.Address,
37
+ Version: types.DiscoveryVersion,
38
IssuedAt: issuedAt,
39
ExpiresAt: issuedAt.Add(DiscoveryDescriptorTTL),
40
APIHTTPSAddr: relayURL,
50
- Discovery: true,
41
}, signing.PrivateKey)
42
if err != nil {
43
t.Fatalf("SignRelayDescriptor() error = %v", err)
@@ -59,7 +49,7 @@ func TestInsertAnnouncedAcceptsValidDescriptor(t *testing.T) {
49
set := NewRelaySet(nil)
50
signing := mustSigningIdentity(t)
51
now := time.Now().UTC().Truncate(time.Microsecond)
62
- desc := mustSignedDescriptor(t, signing, "relay-ann", "https://relay-ann.example", now)
52
+ desc := mustSignedDescriptor(t, signing, "https://relay-ann.example", now)
53
if err := set.InsertAnnounced(desc, now); err != nil {
54
t.Fatalf("InsertAnnounced() error = %v", err)
55
}
@@ -72,7 +62,7 @@ func TestInsertAnnouncedRejectsUnsigned(t *testing.T) {
62
set := NewRelaySet(nil)
63
signing := mustSigningIdentity(t)
64
now := time.Now().UTC().Truncate(time.Microsecond)
75
- desc := mustUnsignedDescriptor(t, signing, "relay-unsigned", "https://relay-unsigned.example")
65
+ desc := mustUnsignedDescriptor(t, signing, "https://relay-unsigned.example")
66
if err := set.InsertAnnounced(desc, now); err == nil {
67
t.Fatal("expected unsigned reject")
68
}
@@ -83,11 +73,11 @@ func TestInsertAnnouncedIgnoresSupersededRollback(t *testing.T) {
73
signing := mustSigningIdentity(t)
74
now := time.Now().UTC().Truncate(time.Microsecond)
75
relayURL := "https://relay-roll.example"
86
- newer := mustSignedDescriptor(t, signing, "relay-roll", relayURL, now)
76
+ newer := mustSignedDescriptor(t, signing, relayURL, now)
77
if err := set.InsertAnnounced(newer, now); err != nil {
78
t.Fatalf("seed insert error = %v", err)
79
}
90
- older := mustSignedDescriptor(t, signing, "relay-roll", relayURL, now.Add(-time.Minute))
80
+ older := mustSignedDescriptor(t, signing, relayURL, now.Add(-time.Minute))
81
if err := set.InsertAnnounced(older, now); err != nil {
82
t.Fatalf("superseded insert error = %v", err)
83
}
@@ -105,11 +95,11 @@ func TestInsertAnnouncedRejectsRollbackAcrossRelayURL(t *testing.T) {
95
set := NewRelaySet(nil)
96
signing := mustSigningIdentity(t)
97
now := time.Now().UTC().Truncate(time.Microsecond)
108
- newer := mustSignedDescriptor(t, signing, "relay-roll", "https://relay-roll-new.example", now)
98
+ newer := mustSignedDescriptor(t, signing, "https://relay-roll-new.example", now)
99
if err := set.InsertAnnounced(newer, now); err != nil {
100
t.Fatalf("seed insert error = %v", err)
101
}
112
- older := mustSignedDescriptor(t, signing, "relay-roll", "https://relay-roll-old.example", now.Add(-time.Minute))
102
+ older := mustSignedDescriptor(t, signing, "https://relay-roll-old.example", now.Add(-time.Minute))
103
if err := set.InsertAnnounced(older, now); err == nil {
104
t.Fatal("expected rollback reject")
105
}
@@ -122,12 +112,12 @@ func TestInsertAnnouncedBlocksCrossIdentityTakeover(t *testing.T) {
112
now := time.Now().UTC().Truncate(time.Microsecond)
113
relayURL := "https://relay-takeover.example"
114
125
- ownerDesc := mustSignedDescriptor(t, owner, "relay-takeover", relayURL, now)
115
+ ownerDesc := mustSignedDescriptor(t, owner, relayURL, now)
116
if err := set.InsertAnnounced(ownerDesc, now); err != nil {
117
t.Fatalf("owner insert error = %v", err)
118
}
119
130
- attackerDesc := mustSignedDescriptor(t, attacker, "relay-takeover", relayURL, now.Add(time.Second))
120
+ attackerDesc := mustSignedDescriptor(t, attacker, relayURL, now.Add(time.Second))
121
if err := set.InsertAnnounced(attackerDesc, now); err == nil {
122
t.Fatal("expected takeover reject")
123
}
portal/discovery/policy_test.go
+22
-32
@@ -10,7 +10,7 @@ import (
10
"github.com/gosuda/portal-tunnel/v2/utils"
11
)
12
13
-func mustPolicyRelayDescriptor(t *testing.T, relayName, relayURL string) types.RelayDescriptor {
13
+func mustPolicyRelayDescriptor(t *testing.T, relayURL string) types.RelayDescriptor {
14
t.Helper()
15
16
signing, err := utils.ResolveSecp256k1Identity("")
@@ -19,16 +19,11 @@ func mustPolicyRelayDescriptor(t *testing.T, relayName, relayURL string) types.R
19
}
20
now := time.Now().UTC()
21
signed, err := auth.SignRelayDescriptor(types.RelayDescriptor{
22
- Identity: types.Identity{
23
- Name: relayName,
24
- Address: signing.Address,
25
- },
26
- RelayID: relayURL,
27
- Version: 1,
22
+ Address: signing.Address,
23
+ Version: types.DiscoveryVersion,
24
IssuedAt: now,
25
ExpiresAt: now.Add(time.Hour),
26
APIHTTPSAddr: relayURL,
31
- Discovery: true,
27
}, signing.PrivateKey)
28
if err != nil {
29
t.Fatalf("SignRelayDescriptor() error = %v", err)
@@ -39,30 +34,26 @@ func mustPolicyRelayDescriptor(t *testing.T, relayName, relayURL string) types.R
34
func bootstrapPolicyRelayState(relayURL string) RelayState {
35
return RelayState{
36
Descriptor: types.RelayDescriptor{
42
- Identity: types.Identity{
43
- Name: utils.PortalRootHost(relayURL),
44
- },
45
- RelayID: relayURL,
37
APIHTTPSAddr: relayURL,
38
},
39
Bootstrap: true,
40
}
41
}
42
52
-func confirmedPolicyRelayState(t *testing.T, relayName, relayURL string) RelayState {
43
+func confirmedPolicyRelayState(t *testing.T, relayURL string) RelayState {
44
t.Helper()
45
46
return RelayState{
56
- Descriptor: mustPolicyRelayDescriptor(t, relayName, relayURL),
47
+ Descriptor: mustPolicyRelayDescriptor(t, relayURL),
48
Confirmed: true,
49
LastSeenAt: time.Now().UTC(),
50
}
51
}
52
62
-func confirmedPolicyRelayStateWithRTT(t *testing.T, relayName, relayURL string, rtt time.Duration) RelayState {
53
+func confirmedPolicyRelayStateWithRTT(t *testing.T, relayURL string, rtt time.Duration) RelayState {
54
t.Helper()
55
65
- state := confirmedPolicyRelayState(t, relayName, relayURL)
56
+ state := confirmedPolicyRelayState(t, relayURL)
57
state.DiscoveryRTT = rtt
58
state.DiscoveryRTTAt = time.Now().UTC()
59
return state
@@ -76,8 +67,8 @@ func TestSelectPriorityKeepsExplicitRelaysOutsideAutoLimit(t *testing.T) {
67
68
selected := policy.SelectPriority([]RelayState{
69
bootstrapPolicyRelayState(explicitRelay),
79
- confirmedPolicyRelayState(t, "relay-a", relayA),
80
- confirmedPolicyRelayState(t, "relay-b", relayB),
70
+ confirmedPolicyRelayState(t, relayA),
71
+ confirmedPolicyRelayState(t, relayB),
72
}, ClientState{
73
ExplicitRelayURLs: []string{explicitRelay},
74
MaxActiveRelays: 1,
@@ -112,10 +103,9 @@ func TestSelectAggregateKeepsBootstrapRelayWhenDescriptorExpired(t *testing.T) {
103
func TestSelectAggregateKeepsCollectedRelayEvenWhenNotAdvertisable(t *testing.T) {
104
policy := DefaultRelayPolicy{}
105
state := RelayState{
115
- Descriptor: mustPolicyRelayDescriptor(t, "relay-a", "https://relay-a.example"),
106
+ Descriptor: mustPolicyRelayDescriptor(t, "https://relay-a.example"),
107
LastSeenAt: time.Now().UTC().Add(-31 * 24 * time.Hour),
108
}
118
- state.Descriptor.Discovery = false
109
state.Descriptor.ExpiresAt = time.Now().UTC().Add(-time.Second)
110
111
selected := policy.SelectAggregate([]RelayState{state})
@@ -128,7 +118,7 @@ func TestSelectAggregateKeepsCollectedRelayEvenWhenNotAdvertisable(t *testing.T)
118
func TestSelectAggregateIncludesHintedRelayWithoutConfirmation(t *testing.T) {
119
policy := DefaultRelayPolicy{}
120
state := RelayState{
131
- Descriptor: mustPolicyRelayDescriptor(t, "relay-hinted", "https://relay-hinted.example"),
121
+ Descriptor: mustPolicyRelayDescriptor(t, "https://relay-hinted.example"),
122
LastSeenAt: time.Now().UTC(),
123
}
124
@@ -160,8 +150,8 @@ func TestSelectPriorityColdStartSelectsEligibleRelay(t *testing.T) {
150
relayB := "https://relay-b.example"
151
152
selected := policy.SelectPriority([]RelayState{
163
- confirmedPolicyRelayState(t, "relay-a", relayA),
164
- confirmedPolicyRelayState(t, "relay-b", relayB),
153
+ confirmedPolicyRelayState(t, relayA),
154
+ confirmedPolicyRelayState(t, relayB),
155
}, ClientState{
156
MaxActiveRelays: 1,
157
})
@@ -176,9 +166,9 @@ func TestSelectPriorityColdStartSelectsEligibleRelay(t *testing.T) {
166
167
func TestSelectPriorityPrefersConfirmedRelayOverHintedRelay(t *testing.T) {
168
policy := DefaultRelayPolicy{}
179
- confirmedRelay := confirmedPolicyRelayState(t, "relay-confirmed", "https://relay-confirmed.example")
169
+ confirmedRelay := confirmedPolicyRelayState(t, "https://relay-confirmed.example")
170
hintedRelay := RelayState{
181
- Descriptor: mustPolicyRelayDescriptor(t, "relay-hinted", "https://relay-hinted.example"),
171
+ Descriptor: mustPolicyRelayDescriptor(t, "https://relay-hinted.example"),
172
LastSeenAt: time.Now().UTC(),
173
}
174
@@ -204,7 +194,7 @@ func TestSelectPriorityKeepsCurrentRelayOverNewConfirmedRelay(t *testing.T) {
194
195
selected := policy.SelectPriority([]RelayState{
196
bootstrapPolicyRelayState(currentRelay),
207
- confirmedPolicyRelayState(t, "relay-new", newRelay),
197
+ confirmedPolicyRelayState(t, newRelay),
198
}, ClientState{
199
ActiveRelayURLs: []string{currentRelay},
200
MaxActiveRelays: 1,
@@ -220,8 +210,8 @@ func TestSelectPriorityKeepsCurrentRelayOverNewConfirmedRelay(t *testing.T) {
210
211
func TestSelectPriorityPushesHighRTTRelayBehindNormalRelay(t *testing.T) {
212
policy := DefaultRelayPolicy{}
223
- normalRelay := confirmedPolicyRelayStateWithRTT(t, "relay-normal", "https://relay-normal.example", 200*time.Millisecond)
224
- highRTTRelay := confirmedPolicyRelayStateWithRTT(t, "relay-high-rtt", "https://relay-high-rtt.example", 1500*time.Millisecond)
213
+ normalRelay := confirmedPolicyRelayStateWithRTT(t, "https://relay-normal.example", 200*time.Millisecond)
214
+ highRTTRelay := confirmedPolicyRelayStateWithRTT(t, "https://relay-high-rtt.example", 1500*time.Millisecond)
215
216
selected := policy.SelectPriority([]RelayState{
217
highRTTRelay,
@@ -242,7 +232,7 @@ func TestOnConfirmedMarksRelayConfirmed(t *testing.T) {
232
policy := DefaultRelayPolicy{}
233
nextDirectRefreshAt := time.Now().UTC().Add(time.Minute)
234
state := RelayState{
245
- Descriptor: mustPolicyRelayDescriptor(t, "relay-a", "https://relay-a.example"),
235
+ Descriptor: mustPolicyRelayDescriptor(t, "https://relay-a.example"),
236
LastSeenAt: time.Now().UTC(),
237
consecutiveFailures: defaultRecoveryFailures,
238
nextDirectRefreshAt: nextDirectRefreshAt,
@@ -263,7 +253,7 @@ func TestOnConfirmedMarksRelayConfirmed(t *testing.T) {
253
254
func TestOnUnconfirmedClearsRelayConfirmation(t *testing.T) {
255
policy := DefaultRelayPolicy{}
266
- state := confirmedPolicyRelayState(t, "relay-a", "https://relay-a.example")
256
+ state := confirmedPolicyRelayState(t, "https://relay-a.example")
257
258
state = policy.OnUnconfirmed(state)
259
@@ -274,7 +264,7 @@ func TestOnUnconfirmedClearsRelayConfirmation(t *testing.T) {
264
265
func TestOnFailureSchedulesDirectRecoveryRetry(t *testing.T) {
266
policy := DefaultRelayPolicy{}
277
- state := confirmedPolicyRelayState(t, "relay-a", "https://relay-a.example")
267
+ state := confirmedPolicyRelayState(t, "https://relay-a.example")
268
startedAt := time.Now().UTC()
269
270
var backedOff bool
@@ -297,7 +287,7 @@ func TestOnFailureSchedulesDirectRecoveryRetry(t *testing.T) {
287
func TestOnFailureSchedulesRetryForHintedRelay(t *testing.T) {
288
policy := DefaultRelayPolicy{}
289
state := RelayState{
300
- Descriptor: mustPolicyRelayDescriptor(t, "relay-hinted", "https://relay-hinted.example"),
290
+ Descriptor: mustPolicyRelayDescriptor(t, "https://relay-hinted.example"),
291
LastSeenAt: time.Now().UTC(),
292
}
293
startedAt := time.Now().UTC()
portal/discovery/refresher.go
-4
@@ -140,10 +140,6 @@ func (r *Refresher) refreshHTTPS(ctx context.Context) error {
140
continue
141
}
142
}
143
- if state.hasObservedDescriptor() && !state.Descriptor.Discovery {
144
- continue
145
- }
146
-
143
relayURL := state.Descriptor.APIHTTPSAddr
144
if relayURL == "" {
145
continue
portal/discovery/relayset.go
+3
-11
@@ -235,12 +235,7 @@ func (s *RelaySet) OverlayPeerStates() []RelayState {
235
s.mu.RLock()
236
out := make([]RelayState, 0, len(s.relays))
237
for _, state := range s.relays {
238
- if state.Banned || !state.hasObservedDescriptor() || !state.Descriptor.ExpiresAt.After(now) || !state.Descriptor.SupportsOverlayPeer {
239
- continue
240
- }
241
- if state.Descriptor.WireGuardPublicKey == "" ||
242
- state.Descriptor.WireGuardEndpoint == "" ||
243
- state.Descriptor.OverlayIPv4 == "" {
238
+ if state.Banned || !state.hasObservedDescriptor() || !state.Descriptor.ExpiresAt.After(now) || !state.Descriptor.HasOverlayPeer() {
239
continue
240
}
241
out = append(out, state)
@@ -261,9 +256,6 @@ func (s *RelaySet) BootstrapRelayURLs() []string {
256
if state.Banned || !state.Bootstrap {
257
continue
258
}
264
- if state.hasObservedDescriptor() && !state.Descriptor.Discovery {
265
- continue
266
- }
259
relayURL := strings.TrimSpace(state.Descriptor.APIHTTPSAddr)
260
if relayURL == "" {
261
continue
@@ -296,12 +288,12 @@ func (s *RelaySet) Descriptors(self types.RelayDescriptor) []types.RelayDescript
288
out = append(out, desc)
289
}
290
299
- if self.APIHTTPSAddr != "" && self.Discovery && self.ExpiresAt.After(now) {
291
+ if self.APIHTTPSAddr != "" && self.ExpiresAt.After(now) {
292
add(self)
293
}
294
s.mu.RLock()
295
for _, state := range s.relays {
304
- if state.Banned || !state.hasObservedDescriptor() || !state.Descriptor.Discovery {
296
+ if state.Banned || !state.hasObservedDescriptor() {
297
continue
298
}
299
add(state.Descriptor)
portal/discovery/relayset_test.go
+8
-16
@@ -10,7 +10,7 @@ import (
10
func TestApplyRelayDiscoveryResponsePreservesBootstrapFlag(t *testing.T) {
11
set := NewRelaySet([]string{"https://relay-a.example"})
12
13
- desc := mustPolicyRelayDescriptor(t, "relay-a", "https://relay-a.example")
13
+ desc := mustPolicyRelayDescriptor(t, "https://relay-a.example")
14
if _, err := set.ApplyRelayDiscoveryResponse(desc.APIHTTPSAddr, types.DiscoveryResponse{
15
ProtocolVersion: types.DiscoveryVersion,
16
Relays: []types.RelayDescriptor{desc},
@@ -32,19 +32,11 @@ func TestDescriptorsDropsExpiredSignedRelayDescriptor(t *testing.T) {
32
33
now := time.Now().UTC()
34
relayURL := "https://relay-stale.example"
35
- state := confirmedPolicyRelayState(t, "relay-stale", relayURL)
35
+ state := confirmedPolicyRelayState(t, relayURL)
36
state.Descriptor.ExpiresAt = now.Add(-time.Minute)
37
state.LastSeenAt = now.Add(-6 * time.Hour)
38
state.Descriptor.SupportsUDP = true
39
state.Descriptor.SupportsTCP = true
40
- state.Descriptor.SupportsOverlayPeer = true
41
- state.Descriptor.IngressTLSAddr = "relay-stale.example:443"
42
- state.Descriptor.WireGuardPublicKey = "pub"
43
- state.Descriptor.WireGuardEndpoint = "relay-stale.example:51820"
44
- state.Descriptor.OverlayIPv4 = "10.0.0.1"
45
- state.Descriptor.OverlayCIDRs = []string{"10.0.0.0/24"}
46
- state.Descriptor.Load = 1
47
- state.Descriptor.LoadScore = 2
40
41
set.mu.Lock()
42
set.relays[relayURL] = state
@@ -59,7 +51,7 @@ func TestDescriptorsDropsExpiredSignedRelayDescriptor(t *testing.T) {
51
func TestApplyRelayDiscoveryResponseCollectsRelaysDespiteProtocolMismatch(t *testing.T) {
52
set := NewRelaySet(nil)
53
62
- desc := mustPolicyRelayDescriptor(t, "relay-mismatch", "https://relay-mismatch.example")
54
+ desc := mustPolicyRelayDescriptor(t, "https://relay-mismatch.example")
55
changed, err := set.ApplyRelayDiscoveryResponse("", types.DiscoveryResponse{
56
ProtocolVersion: "5",
57
Relays: []types.RelayDescriptor{desc},
@@ -86,7 +78,7 @@ func TestApplyRelayDiscoveryResponseCollectsRelaysDespiteProtocolMismatch(t *tes
78
func TestApplyRelayDiscoveryResponseCollectsHintsWhenTargetDescriptorIsMissing(t *testing.T) {
79
set := NewRelaySet(nil)
80
89
- hinted := mustPolicyRelayDescriptor(t, "relay-hinted", "https://relay-hinted.example")
81
+ hinted := mustPolicyRelayDescriptor(t, "https://relay-hinted.example")
82
changed, err := set.ApplyRelayDiscoveryResponse("https://relay-source.example", types.DiscoveryResponse{
83
ProtocolVersion: "5",
84
Relays: []types.RelayDescriptor{hinted},
@@ -114,7 +106,7 @@ func TestApplyRelayDiscoveryResponseClearsDirectRetryOnAuthoritativeSuccess(t *t
106
set := NewRelaySet(nil)
107
108
relayURL := "https://relay-source.example"
117
- desc := mustPolicyRelayDescriptor(t, "relay-source", relayURL)
109
+ desc := mustPolicyRelayDescriptor(t, relayURL)
110
set.mu.Lock()
111
state := RelayState{
112
Descriptor: desc,
@@ -147,7 +139,7 @@ func TestApplyRelayDiscoveryResponsePreservesDirectRetryOnHint(t *testing.T) {
139
set := NewRelaySet(nil)
140
141
relayURL := "https://relay-hinted.example"
150
- desc := mustPolicyRelayDescriptor(t, "relay-hinted", relayURL)
142
+ desc := mustPolicyRelayDescriptor(t, relayURL)
143
nextDirectRefreshAt := time.Now().UTC().Add(time.Minute)
144
set.mu.Lock()
145
state := RelayState{
@@ -178,7 +170,7 @@ func TestConfirmRelayURLMarksRelayConfirmedWithoutChangingAggregateDescriptor(t
170
171
relayURL := "https://relay-confirmed.example"
172
state := RelayState{
181
- Descriptor: mustPolicyRelayDescriptor(t, "relay-confirmed", relayURL),
173
+ Descriptor: mustPolicyRelayDescriptor(t, relayURL),
174
LastSeenAt: time.Now().UTC(),
175
}
176
@@ -203,7 +195,7 @@ func TestUnconfirmRelayURLClearsLocalConfirmationOnly(t *testing.T) {
195
set := NewRelaySet(nil)
196
197
relayURL := "https://relay-confirmed.example"
206
- state := confirmedPolicyRelayState(t, "relay-confirmed", relayURL)
198
+ state := confirmedPolicyRelayState(t, relayURL)
199
200
set.mu.Lock()
201
set.relays[relayURL] = state
portal/discovery/relaystate.go
-5
@@ -4,7 +4,6 @@ import (
4
"time"
5
6
"github.com/gosuda/portal-tunnel/v2/types"
7
- "github.com/gosuda/portal-tunnel/v2/utils"
7
)
8
9
const (
@@ -47,10 +46,6 @@ type RelayState struct {
46
func newRelayState(relayURL string) RelayState {
47
return RelayState{
48
Descriptor: types.RelayDescriptor{
50
- Identity: types.Identity{
51
- Name: utils.PortalRootHost(relayURL),
52
- },
53
- RelayID: relayURL,
49
APIHTTPSAddr: relayURL,
50
},
51
}
portal/lease.go
+3
-3
@@ -217,7 +217,7 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) e
217
}
218
matchHostname := utils.NormalizeHostname(route.MatchHostname)
219
matchToken := strings.TrimSpace(route.MatchToken)
220
- overlayIPv4 := strings.TrimSpace(route.ForwardRelay.OverlayIPv4)
220
+ overlayIPv4, overlayErr := utils.DeriveWireGuardOverlayIPv4(route.ForwardRelay.WireGuardPublicKey)
221
forwardToken := strings.TrimSpace(route.ForwardToken)
222
expiresAt := route.ExpiresAt.UTC()
223
@@ -230,8 +230,8 @@ func (r *leaseRegistry) RegisterHopRoute(route *types.HopRoute, now time.Time) e
230
return errors.New("hostname or token matcher is required")
231
case matchHostname != "" && matchToken != "":
232
return errors.New("hostname and token matchers are mutually exclusive")
233
- case overlayIPv4 == "":
234
- return errors.New("forward overlay ipv4 is required")
233
+ case overlayErr != nil:
234
+ return fmt.Errorf("forward relay overlay ipv4: %w", overlayErr)
235
case forwardToken == "":
236
return errors.New("forward token is required")
237
}
portal/overlay/overlay.go
+32
-43
@@ -23,29 +23,23 @@ type desiredPeer struct {
23
}
24
25
type Config struct {
26
- PrivateKey string
27
- PublicKey string
28
- Endpoint string
29
- OverlayIPv4 string
30
- OverlayCIDRs []string
26
+ PrivateKey string
27
+ PublicKey string
28
+ ListenPort int
29
}
30
31
func (c Config) Copy() Config {
32
return Config{
35
- PrivateKey: c.PrivateKey,
36
- PublicKey: c.PublicKey,
37
- Endpoint: c.Endpoint,
38
- OverlayIPv4: c.OverlayIPv4,
39
- OverlayCIDRs: append([]string(nil), c.OverlayCIDRs...),
33
+ PrivateKey: c.PrivateKey,
34
+ PublicKey: c.PublicKey,
35
+ ListenPort: c.ListenPort,
36
}
37
}
38
43
-func NormalizeConfig(rootHost string, cfg Config) (Config, error) {
39
+func NormalizeConfig(cfg Config) (Config, error) {
40
configured := strings.TrimSpace(cfg.PrivateKey) != "" ||
41
strings.TrimSpace(cfg.PublicKey) != "" ||
46
- strings.TrimSpace(cfg.Endpoint) != "" ||
47
- strings.TrimSpace(cfg.OverlayIPv4) != "" ||
48
- len(cfg.OverlayCIDRs) > 0
42
+ cfg.ListenPort != 0
43
if !configured {
44
return cfg, nil
45
}
@@ -68,26 +62,11 @@ func NormalizeConfig(rootHost string, cfg Config) (Config, error) {
62
63
cfg.PrivateKey = privateKey
64
cfg.PublicKey = publicKey
71
- if len(cfg.OverlayCIDRs) > 0 {
72
- cfg.OverlayCIDRs, err = utils.NormalizeOverlayCIDRs(cfg.OverlayCIDRs)
73
- if err != nil {
74
- return Config{}, fmt.Errorf("normalize overlay cidrs: %w", err)
75
- }
76
- }
77
- if strings.TrimSpace(cfg.Endpoint) == "" {
78
- cfg.Endpoint = net.JoinHostPort(rootHost, fmt.Sprintf("%d", DefaultListenPort))
79
- }
80
- if strings.TrimSpace(cfg.OverlayIPv4) == "" {
81
- cfg.OverlayIPv4, err = utils.DeriveWireGuardOverlayIPv4(cfg.PublicKey)
82
- if err != nil {
83
- return Config{}, fmt.Errorf("derive overlay ipv4: %w", err)
84
- }
65
+ if cfg.ListenPort == 0 {
66
+ cfg.ListenPort = DefaultListenPort
67
}
86
- if err := utils.ValidateWireGuardEndpoint(cfg.Endpoint); err != nil {
87
- return Config{}, err
88
- }
89
- if err := utils.ValidateOverlayIPv4(cfg.OverlayIPv4); err != nil {
90
- return Config{}, err
68
+ if cfg.ListenPort < 0 || cfg.ListenPort > 65535 {
69
+ return Config{}, errors.New("wireguard listen port is invalid")
70
}
71
return cfg, nil
72
}
@@ -99,8 +78,8 @@ type Overlay struct {
78
server *http.Server
79
}
80
102
-func NewOverlay(rootHost string, cfg Config, handler http.Handler) (*Overlay, error) {
103
- cfg, err := NormalizeConfig(rootHost, cfg)
81
+func NewOverlay(cfg Config, handler http.Handler) (*Overlay, error) {
82
+ cfg, err := NormalizeConfig(cfg)
83
if err != nil {
84
return nil, err
85
}
@@ -194,14 +173,18 @@ func (o *Overlay) DiscoverRelay(ctx context.Context, relay types.RelayDescriptor
173
if o == nil || o.stack == nil {
174
return types.DiscoveryResponse{}, errors.New("overlay is not initialized")
175
}
197
- if strings.TrimSpace(relay.OverlayIPv4) == "" {
198
- return types.DiscoveryResponse{}, errors.New("relay overlay ipv4 is required")
176
+ if !relay.HasOverlayPeer() {
177
+ return types.DiscoveryResponse{}, errors.New("relay wireguard overlay metadata is required")
178
+ }
179
+ overlayIPv4, err := utils.DeriveWireGuardOverlayIPv4(relay.WireGuardPublicKey)
180
+ if err != nil {
181
+ return types.DiscoveryResponse{}, err
182
}
183
184
var resp types.DiscoveryResponse
185
baseURL := &url.URL{
186
Scheme: "http",
204
- Host: net.JoinHostPort(relay.OverlayIPv4, fmt.Sprintf("%d", DefaultPeerAPIHTTPPort)),
187
+ Host: net.JoinHostPort(overlayIPv4, fmt.Sprintf("%d", DefaultPeerAPIHTTPPort)),
188
}
189
if err := utils.HTTPDoAPIPath(ctx, o.Client(), baseURL, http.MethodGet, types.PathDiscovery, nil, nil, &resp); err != nil {
190
return types.DiscoveryResponse{}, err
@@ -219,7 +202,7 @@ func (o *Overlay) Sync(relays []discovery.RelayState) error {
202
func peersForRelays(publicKey string, relays []discovery.RelayState) []desiredPeer {
203
peers := make([]desiredPeer, 0, len(relays))
204
for _, relay := range relays {
222
- if !relay.Descriptor.SupportsOverlayPeer {
205
+ if !relay.Descriptor.HasOverlayPeer() {
206
continue
207
}
208
@@ -228,12 +211,18 @@ func peersForRelays(publicKey string, relays []discovery.RelayState) []desiredPe
211
continue
212
}
213
231
- allowedIPs := []string{desc.OverlayIPv4 + "/32"}
232
- allowedIPs = append(allowedIPs, desc.OverlayCIDRs...)
214
+ overlayIPv4, err := utils.DeriveWireGuardOverlayIPv4(desc.WireGuardPublicKey)
215
+ if err != nil {
216
+ continue
217
+ }
218
+ wireGuardEndpoint, err := utils.RelayWireGuardEndpoint(desc)
219
+ if err != nil {
220
+ continue
221
+ }
222
peers = append(peers, desiredPeer{
223
wireGuardPublicKey: desc.WireGuardPublicKey,
235
- wireGuardEndpoint: desc.WireGuardEndpoint,
236
- allowedIPs: allowedIPs,
224
+ wireGuardEndpoint: wireGuardEndpoint,
225
+ allowedIPs: []string{overlayIPv4 + "/32"},
226
})
227
}
228
sort.Slice(peers, func(i, j int) bool {
portal/overlay/stack.go
+8
-4
@@ -45,12 +45,16 @@ func newStack(cfg Config) (*stack, error) {
45
return nil, fmt.Errorf("normalize wireguard private key: %w", err)
46
}
47
48
- listenPort, err := utils.WireGuardListenPort(cfg.Endpoint)
49
- if err != nil {
50
- return nil, err
48
+ listenPort := cfg.ListenPort
49
+ if listenPort <= 0 || listenPort > 65535 {
50
+ return nil, errors.New("wireguard listen port is invalid")
51
}
52
53
- overlayIP, err := netip.ParseAddr(cfg.OverlayIPv4)
53
+ overlayIPv4, err := utils.DeriveWireGuardOverlayIPv4(cfg.PublicKey)
54
+ if err != nil {
55
+ return nil, fmt.Errorf("derive overlay ipv4: %w", err)
56
+ }
57
+ overlayIP, err := netip.ParseAddr(overlayIPv4)
58
if err != nil || !overlayIP.Is4() {
59
return nil, errors.New("overlay ipv4 must be a valid IPv4 address")
60
}
portal/server.go
+3
-2
@@ -80,6 +80,7 @@ func normalizeServerConfig(cfg ServerConfig) (ServerConfig, error) {
80
81
cfg.APIPort = utils.IntOrDefault(cfg.APIPort, 4017)
82
cfg.SNIPort = utils.IntOrDefault(cfg.SNIPort, 443)
83
+ cfg.WireGuardPort = utils.IntOrDefault(cfg.WireGuardPort, overlay.DefaultListenPort)
84
cfg.APIListenAddr = utils.StringOrDefault(cfg.APIListenAddr, fmt.Sprintf(":%d", cfg.APIPort))
85
cfg.SNIListenAddr = utils.StringOrDefault(cfg.SNIListenAddr, fmt.Sprintf(":%d", cfg.SNIPort))
86
@@ -642,10 +643,10 @@ func (s *Server) startOverlay() (*overlay.Overlay, error) {
643
peerMux.HandleFunc(types.PathDiscovery, s.handleRelayDiscovery)
644
}
645
645
- overlay, err := overlay.NewOverlay(s.identity.Name, overlay.Config{
646
+ overlay, err := overlay.NewOverlay(overlay.Config{
647
PrivateKey: s.identity.WireGuardPrivateKey,
648
PublicKey: s.identity.WireGuardPublicKey,
648
- Endpoint: net.JoinHostPort(s.identity.Name, fmt.Sprintf("%d", utils.IntOrDefault(s.cfg.WireGuardPort, overlay.DefaultListenPort))),
649
+ ListenPort: s.cfg.WireGuardPort,
650
}, peerMux)
651
if err != nil {
652
return nil, fmt.Errorf("start wireguard overlay: %w", err)
sdk/api_client.go
+3
@@ -112,6 +112,9 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
112
if !ok {
113
return types.RegisterResponse{}, nil, fmt.Errorf("multi-hop relay %d descriptor was not discovered", i)
114
}
115
+ if !desc.SupportsOverlay {
116
+ return types.RegisterResponse{}, nil, fmt.Errorf("multi-hop relay %d does not support overlay", i)
117
+ }
118
hopPath = append(hopPath, desc)
119
}
120
types/identity.go
+44
-60
@@ -124,76 +124,60 @@ type AdminLease struct {
124
}
125
126
type RelayDescriptor struct {
127
- Identity
127
+ Address string `json:"address"`
128
+ Version string `json:"version"`
129
+ IssuedAt time.Time `json:"issued_at"`
130
+ ExpiresAt time.Time `json:"expires_at"`
131
+ APIHTTPSAddr string `json:"api_https_addr"`
132
+ WireGuardPublicKey string `json:"wireguard_public_key,omitempty"`
133
+ WireGuardPort int `json:"wireguard_port,omitempty"`
134
+ SupportsOverlay bool `json:"supports_overlay,omitempty"`
135
+ SupportsUDP bool `json:"supports_udp,omitempty"`
136
+ SupportsTCP bool `json:"supports_tcp,omitempty"`
137
+ ActiveConnections int64 `json:"active_connections,omitempty"`
138
+ TCPBPS float64 `json:"tcp_bps,omitempty"`
139
+ Signature string `json:"signature,omitempty"`
140
+}
141
129
- RelayID string `json:"relay_id,omitempty"`
130
- OwnerAddress string `json:"owner_address,omitempty"`
131
- Version uint32 `json:"version"`
132
- IssuedAt time.Time `json:"issued_at"`
133
- ExpiresAt time.Time `json:"expires_at"`
134
- APIHTTPSAddr string `json:"api_https_addr"`
135
- IngressTLSAddr string `json:"ingress_tls_addr,omitempty"`
136
- WireGuardPublicKey string `json:"wireguard_public_key,omitempty"`
137
- WireGuardEndpoint string `json:"wireguard_endpoint,omitempty"`
138
- OverlayIPv4 string `json:"overlay_ipv4,omitempty"`
139
- OverlayCIDRs []string `json:"overlay_cidrs,omitempty"`
140
- Discovery bool `json:"discovery,omitempty"`
141
- SupportsUDP bool `json:"supports_udp,omitempty"`
142
- SupportsTCP bool `json:"supports_tcp,omitempty"`
143
- SupportsOverlayPeer bool `json:"supports_overlay_peer,omitempty"`
144
- Load float64 `json:"load,omitempty"`
145
- LoadScore float64 `json:"load_score,omitempty"`
146
- LastUpdated int64 `json:"last_updated,omitempty"`
147
- Signature string `json:"signature,omitempty"`
142
+func (desc RelayDescriptor) HasOverlayPeer() bool {
143
+ return desc.SupportsOverlay &&
144
+ strings.TrimSpace(desc.WireGuardPublicKey) != "" &&
145
+ desc.WireGuardPort > 0 &&
146
+ desc.WireGuardPort <= 65535
147
}
148
149
// CanonicalBytes returns the deterministic byte representation of a relay
151
-// descriptor used for signing and signature verification. Two descriptors
152
-// that differ only in mutable telemetry fields produce identical bytes.
150
+// descriptor used for signing and signature verification.
151
//
152
// The encoding is JSON over a fixed struct schema (no maps, no omitempty),
153
// which guarantees field order and presence regardless of input variation.
154
func CanonicalBytes(desc RelayDescriptor) ([]byte, error) {
157
- overlayCIDRs := desc.OverlayCIDRs
158
- if overlayCIDRs == nil {
159
- overlayCIDRs = []string{}
160
- }
155
canonical := struct {
162
- Name string `json:"name"`
163
- Address string `json:"address"`
164
- RelayID string `json:"relay_id"`
165
- OwnerAddress string `json:"owner_address"`
166
- Version uint32 `json:"version"`
167
- IssuedAtUnixNano int64 `json:"issued_at_unix_nano"`
168
- ExpiresAtUnixNano int64 `json:"expires_at_unix_nano"`
169
- APIHTTPSAddr string `json:"api_https_addr"`
170
- IngressTLSAddr string `json:"ingress_tls_addr"`
171
- WireGuardPublicKey string `json:"wireguard_public_key"`
172
- WireGuardEndpoint string `json:"wireguard_endpoint"`
173
- OverlayIPv4 string `json:"overlay_ipv4"`
174
- OverlayCIDRs []string `json:"overlay_cidrs"`
175
- Discovery bool `json:"discovery"`
176
- SupportsUDP bool `json:"supports_udp"`
177
- SupportsTCP bool `json:"supports_tcp"`
178
- SupportsOverlayPeer bool `json:"supports_overlay_peer"`
156
+ Address string `json:"address"`
157
+ Version string `json:"version"`
158
+ IssuedAtUnixNano int64 `json:"issued_at_unix_nano"`
159
+ ExpiresAtUnixNano int64 `json:"expires_at_unix_nano"`
160
+ APIHTTPSAddr string `json:"api_https_addr"`
161
+ WireGuardPublicKey string `json:"wireguard_public_key"`
162
+ WireGuardPort int `json:"wireguard_port"`
163
+ SupportsOverlay bool `json:"supports_overlay"`
164
+ SupportsUDP bool `json:"supports_udp"`
165
+ SupportsTCP bool `json:"supports_tcp"`
166
+ ActiveConnections int64 `json:"active_connections"`
167
+ TCPBPS float64 `json:"tcp_bps"`
168
}{
180
- Name: desc.Name,
181
- Address: desc.Address,
182
- RelayID: desc.RelayID,
183
- OwnerAddress: desc.OwnerAddress,
184
- Version: desc.Version,
185
- IssuedAtUnixNano: desc.IssuedAt.UTC().UnixNano(),
186
- ExpiresAtUnixNano: desc.ExpiresAt.UTC().UnixNano(),
187
- APIHTTPSAddr: desc.APIHTTPSAddr,
188
- IngressTLSAddr: desc.IngressTLSAddr,
189
- WireGuardPublicKey: desc.WireGuardPublicKey,
190
- WireGuardEndpoint: desc.WireGuardEndpoint,
191
- OverlayIPv4: desc.OverlayIPv4,
192
- OverlayCIDRs: overlayCIDRs,
193
- Discovery: desc.Discovery,
194
- SupportsUDP: desc.SupportsUDP,
195
- SupportsTCP: desc.SupportsTCP,
196
- SupportsOverlayPeer: desc.SupportsOverlayPeer,
169
+ Address: desc.Address,
170
+ Version: desc.Version,
171
+ IssuedAtUnixNano: desc.IssuedAt.UTC().UnixNano(),
172
+ ExpiresAtUnixNano: desc.ExpiresAt.UTC().UnixNano(),
173
+ APIHTTPSAddr: desc.APIHTTPSAddr,
174
+ WireGuardPublicKey: desc.WireGuardPublicKey,
175
+ WireGuardPort: desc.WireGuardPort,
176
+ SupportsOverlay: desc.SupportsOverlay,
177
+ SupportsUDP: desc.SupportsUDP,
178
+ SupportsTCP: desc.SupportsTCP,
179
+ ActiveConnections: desc.ActiveConnections,
180
+ TCPBPS: desc.TCPBPS,
181
}
182
return json.Marshal(canonical)
183
}
types/types.go
+1
-1
@@ -3,7 +3,7 @@ package types
3
const (
4
ReleaseVersion = "v2.1.5"
5
SDKVersion = "6"
6
- DiscoveryVersion = "6"
6
+ DiscoveryVersion = "7"
7
PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal-tunnel/main/registry.json"
8
9
HeaderAccessToken = "X-Portal-Access-Token"
utils/crypto.go
-76
@@ -7,10 +7,7 @@ import (
7
"encoding/hex"
8
"errors"
9
"fmt"
10
- "net"
10
"net/netip"
12
- "sort"
13
- "strconv"
11
"strings"
12
13
"github.com/decred/dcrd/dcrec/secp256k1/v4"
@@ -299,22 +296,6 @@ func WireGuardPublicKeyFromPrivate(raw string) (string, error) {
296
return base64.StdEncoding.EncodeToString(publicKey[:]), nil
297
}
298
302
-func WireGuardListenPort(rawEndpoint string) (int, error) {
303
- endpoint := strings.TrimSpace(rawEndpoint)
304
- if endpoint == "" {
305
- return 0, errors.New("wireguard endpoint is required")
306
- }
307
- _, portText, err := net.SplitHostPort(endpoint)
308
- if err != nil {
309
- return 0, errors.New("wireguard endpoint must be host:port")
310
- }
311
- port, err := strconv.Atoi(portText)
312
- if err != nil || port <= 0 || port > 65535 {
313
- return 0, errors.New("wireguard endpoint port is invalid")
314
- }
315
- return port, nil
316
-}
317
-
299
func DeriveWireGuardOverlayIPv4(publicKey string) (string, error) {
300
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(publicKey))
301
if err != nil {
@@ -385,63 +366,6 @@ func ValidateWireGuardPublicKey(raw string) error {
366
return nil
367
}
368
388
-func ValidateWireGuardEndpoint(raw string) error {
389
- endpoint := strings.TrimSpace(raw)
390
- if endpoint == "" {
391
- return errors.New("wireguard_endpoint is required")
392
- }
393
- host, port, err := net.SplitHostPort(endpoint)
394
- if err != nil {
395
- return errors.New("wireguard_endpoint must be host:port")
396
- }
397
- if strings.TrimSpace(host) == "" {
398
- return errors.New("wireguard_endpoint host is required")
399
- }
400
- portNum, err := strconv.Atoi(port)
401
- if err != nil || portNum <= 0 || portNum > 65535 {
402
- return errors.New("wireguard_endpoint port is invalid")
403
- }
404
- return nil
405
-}
406
-
407
-func ValidateOverlayIPv4(raw string) error {
408
- ipText := strings.TrimSpace(raw)
409
- if ipText == "" {
410
- return errors.New("overlay_ipv4 is required")
411
- }
412
- ip := net.ParseIP(ipText)
413
- if ip == nil || ip.To4() == nil {
414
- return errors.New("overlay_ipv4 must be a valid IPv4 address")
415
- }
416
- return nil
417
-}
418
-
419
-func NormalizeOverlayCIDRs(inputs []string) ([]string, error) {
420
- if len(inputs) == 0 {
421
- return nil, nil
422
- }
423
- seen := make(map[string]struct{}, len(inputs))
424
- out := make([]string, 0, len(inputs))
425
- for _, input := range inputs {
426
- input = strings.TrimSpace(input)
427
- if input == "" {
428
- continue
429
- }
430
- _, network, err := net.ParseCIDR(input)
431
- if err != nil {
432
- return nil, fmt.Errorf("invalid overlay cidr %q", input)
433
- }
434
- normalized := network.String()
435
- if _, ok := seen[normalized]; ok {
436
- continue
437
- }
438
- seen[normalized] = struct{}{}
439
- out = append(out, normalized)
440
- }
441
- sort.Strings(out)
442
- return out, nil
443
-}
444
-
369
func ParseSecp256k1PublicKeyHex(raw string) (*secp256k1.PublicKey, error) {
370
publicKeyHex := strings.TrimSpace(raw)
371
if publicKeyHex == "" {
utils/identity.go
+38
-29
@@ -4,6 +4,7 @@ import (
4
"encoding/json"
5
"errors"
6
"fmt"
7
+ "math"
8
"net"
9
"net/url"
10
"os"
@@ -31,16 +32,13 @@ func NormalizeIdentity(identity types.Identity) (types.Identity, error) {
32
}
33
34
func NormalizeDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, error) {
34
- desc.Name = NormalizeHostname(desc.Name)
35
desc.Address = strings.TrimSpace(desc.Address)
36
+ desc.Version = strings.TrimSpace(desc.Version)
37
desc.APIHTTPSAddr = strings.TrimSpace(desc.APIHTTPSAddr)
37
- desc.RelayID = strings.TrimSpace(desc.RelayID)
38
- desc.IngressTLSAddr = strings.TrimSpace(desc.IngressTLSAddr)
38
desc.WireGuardPublicKey = strings.TrimSpace(desc.WireGuardPublicKey)
40
- desc.WireGuardEndpoint = strings.TrimSpace(desc.WireGuardEndpoint)
41
- desc.OverlayIPv4 = strings.TrimSpace(desc.OverlayIPv4)
42
- desc.OverlayCIDRs = NormalizeIPPrefixes(desc.OverlayCIDRs)
43
- desc.OwnerAddress = strings.TrimSpace(desc.OwnerAddress)
39
+ if desc.Version == "" {
40
+ desc.Version = types.DiscoveryVersion
41
+ }
42
if !desc.IssuedAt.IsZero() {
43
desc.IssuedAt = desc.IssuedAt.UTC()
44
}
@@ -55,16 +53,6 @@ func NormalizeDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, err
53
}
54
desc.APIHTTPSAddr = normalized
55
}
58
- if desc.RelayID != "" {
59
- normalized, err := NormalizeRelayURL(desc.RelayID)
60
- if err != nil {
61
- return types.RelayDescriptor{}, fmt.Errorf("normalize relay id: %w", err)
62
- }
63
- desc.RelayID = normalized
64
- }
65
- if desc.RelayID == "" {
66
- desc.RelayID = desc.APIHTTPSAddr
67
- }
56
if desc.Address != "" {
57
normalized, err := NormalizeEVMAddress(desc.Address)
58
if err != nil {
@@ -72,24 +60,34 @@ func NormalizeDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, err
60
}
61
desc.Address = normalized
62
}
75
- if desc.OwnerAddress == "" {
76
- desc.OwnerAddress = desc.Address
77
- }
78
- if desc.OwnerAddress != "" {
79
- normalized, err := NormalizeEVMAddress(desc.OwnerAddress)
80
- if err != nil {
81
- return types.RelayDescriptor{}, fmt.Errorf("normalize owner address: %w", err)
63
+ if desc.WireGuardPublicKey != "" {
64
+ if err := ValidateWireGuardPublicKey(desc.WireGuardPublicKey); err != nil {
65
+ return types.RelayDescriptor{}, err
66
}
83
- desc.OwnerAddress = normalized
67
+ }
68
+ if desc.WireGuardPort < 0 || desc.WireGuardPort > 65535 {
69
+ return types.RelayDescriptor{}, errors.New("wireguard_port is invalid")
70
+ }
71
+ if desc.ActiveConnections < 0 {
72
+ return types.RelayDescriptor{}, errors.New("active_connections is invalid")
73
+ }
74
+ if desc.TCPBPS < 0 || math.IsNaN(desc.TCPBPS) || math.IsInf(desc.TCPBPS, 0) {
75
+ return types.RelayDescriptor{}, errors.New("tcp_bps is invalid")
76
}
77
78
switch {
87
- case desc.Name == "":
88
- return types.RelayDescriptor{}, errors.New("identity.name is required")
79
+ case desc.Address == "":
80
+ return types.RelayDescriptor{}, errors.New("address is required")
81
+ case desc.Version != types.DiscoveryVersion:
82
+ return types.RelayDescriptor{}, fmt.Errorf("unsupported relay descriptor version %q", desc.Version)
83
case desc.APIHTTPSAddr == "":
84
return types.RelayDescriptor{}, errors.New("api_https_addr is required")
91
- case desc.RelayID != desc.APIHTTPSAddr:
92
- return types.RelayDescriptor{}, errors.New("relay_id must match api_https_addr")
85
+ case desc.SupportsOverlay && desc.WireGuardPublicKey == "":
86
+ return types.RelayDescriptor{}, errors.New("wireguard_public_key is required when supports_overlay is set")
87
+ case desc.SupportsOverlay && desc.WireGuardPort == 0:
88
+ return types.RelayDescriptor{}, errors.New("wireguard_port is required when supports_overlay is set")
89
+ case !desc.SupportsOverlay && (desc.WireGuardPublicKey != "" || desc.WireGuardPort != 0):
90
+ return types.RelayDescriptor{}, errors.New("supports_overlay is required when wireguard metadata is set")
91
case desc.ExpiresAt.IsZero():
92
return types.RelayDescriptor{}, errors.New("expires_at is required")
93
case desc.IssuedAt.After(desc.ExpiresAt):
@@ -99,6 +97,17 @@ func NormalizeDescriptor(desc types.RelayDescriptor) (types.RelayDescriptor, err
97
return desc, nil
98
}
99
100
+func RelayWireGuardEndpoint(desc types.RelayDescriptor) (string, error) {
101
+ host := PortalRootHost(desc.APIHTTPSAddr)
102
+ if host == "" {
103
+ return "", errors.New("api_https_addr host is required")
104
+ }
105
+ if desc.WireGuardPort <= 0 || desc.WireGuardPort > 65535 {
106
+ return "", errors.New("wireguard_port is invalid")
107
+ }
108
+ return net.JoinHostPort(host, fmt.Sprintf("%d", desc.WireGuardPort)), nil
109
+}
110
+
111
func ResolveRelayStateDir(path string) string {
112
trimmed := strings.TrimSpace(path)
113
if trimmed == "" {