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 == "" {