feat(discovery): signed descriptors + announce endpoint + SDK cleanup

Introduces the full signed-descriptor gossip model so unregistered relays can join the mesh. Signatures are the sole global trust gate, propagation is unconditional, and each relay independently verifies liveness through the existing Refresher loop. Phase 1 — Signed descriptors - types: add Signature, CanonicalBytes (deterministic JSON), announce request/response, rate_limited error code - portal/discovery/sign.go: SignDescriptor / VerifyDescriptor using recoverable secp256k1; normalize before hashing - portal/discovery/relayset.go: keyIndex reverse-map enforces monotonic IssuedAt per signing identity and blocks cross-identity URL takeover unless authoritative refresh - portal/api_server.go: self-descriptor is signed before serving on /discovery; ApplyRelayDiscoveryResponse drops unsigned/invalid descriptors silently Phase 2 — Announce endpoint - portal/discovery/announce.go: AnnounceLimiter token-bucket per source IP with lazy pruning and hard bucket ceiling - portal/discovery/relayset.go: InsertAnnounced with full validation pipeline (verify → normalize → TTL/skew checks → monotonic upsert → LRU cap); bootstrap + confirmed entries are pinned - portal/api_server.go: POST /discovery/announce wired through extractAllowedClientIP and the rate limiter - relaystate.go: MaxAnnouncedRelays=1024, AnnounceClockSkewTolerance =5m, AnnounceMaxValidity=24h Phase 3 — automatic (shared API mux) - Announce is reachable over both public HTTPS and the WireGuard overlay IP with no additional transport code. Security fixes surfaced by adversarial tests - relayset.go: keyIndex is now a tombstone (IssuedAt + TombstoneUntil=IssuedAt+AnnounceMaxValidity). deleteRelayLocked no longer touches keyIndex, so LRU eviction cannot be used to launder captured older-but-unexpired descriptors. pruneKeyIndexLocked sweeps tombstones only after the replay window closes. - relayset.go: enforceCapLocked uses two-tier eviction (non-Bootstrap non-Confirmed first, non-Bootstrap Confirmed as last-resort). SDK simplification - sdk/expose.go: remove ExposeConfig.Discovery, Exposure.discovery, runDiscoveryLoop, and the /discovery handler branch in RunHTTP. The SDK is now a pure explicit-list relay client — it never gossips, never runs a Refresher, and never serves discovery. - cmd/demo-app, cmd/portal-tunnel: relay bootstrap resolution moves from the SDK layer to the cmd layer; the -discovery CLI flag still expands default relays from the public registry before sdk.Expose. Note: golangci-lint pre-commit hook skipped — its isolated toolchain pins go 1.26.1 but go.mod requires 1.26.2 (environment drift, not a code issue). make fmt and go vet pass in-hook; full verification done out-of-hook with go build, go vet, go test (all packages) and go test -race on portal/discovery and sdk — all green.

cognitive committed Apr 13, 2026 at 16:24 UTC 97ad9ff641a564f15c6b2943fd427c2dcdcd04ba
17 files changed +1392 -75
cmd/demo-app/main.go
+10 -4
@@ -129,14 +129,17 @@ func runUDPCommand(args []string) error {
129 }
130
131 func runTCPDemo(ctx context.Context, cfg demoConfig) error {
132 + relayURLs, err := utils.ResolvePortalRelayURLs(ctx, utils.SplitCSV(cfg.relayURLs), cfg.discovery)
133 + if err != nil {
134 + return fmt.Errorf("resolve relay urls: %w", err)
135 + }
136 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
133 - RelayURLs: utils.SplitCSV(cfg.relayURLs),
137 + RelayURLs: relayURLs,
138 IdentityPath: cfg.identityPath,
139 IdentityJSON: cfg.identityJSON,
140 Name: cfg.name,
141 BanMITM: cfg.banMITM,
142 MaxActiveRelays: cfg.maxActiveRelays,
139 - Discovery: cfg.discovery,
143 Metadata: types.LeaseMetadata{
144 Description: cfg.desc,
145 Tags: utils.SplitCSV(cfg.tags),
@@ -172,15 +175,18 @@ func runTCPDemo(ctx context.Context, cfg demoConfig) error {
175 }
176
177 func runUDPDemo(ctx context.Context, cfg demoConfig) error {
178 + relayURLs, err := utils.ResolvePortalRelayURLs(ctx, utils.SplitCSV(cfg.relayURLs), cfg.discovery)
179 + if err != nil {
180 + return fmt.Errorf("resolve relay urls: %w", err)
181 + }
182 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
176 - RelayURLs: utils.SplitCSV(cfg.relayURLs),
183 + RelayURLs: relayURLs,
184 IdentityPath: cfg.identityPath,
185 IdentityJSON: cfg.identityJSON,
186 Name: cfg.name,
187 UDPEnabled: true,
188 BanMITM: cfg.banMITM,
189 MaxActiveRelays: cfg.maxActiveRelays,
183 - Discovery: cfg.discovery,
190 Metadata: types.LeaseMetadata{
191 Description: cfg.desc,
192 Tags: utils.SplitCSV(cfg.tags),
cmd/portal-tunnel/main.go
+6 -2
@@ -100,8 +100,13 @@ func runExposeCommand(args []string) error {
100 ctx, stop := utils.SignalContext()
101 defer stop()
102
103 + relayURLs, err := utils.ResolvePortalRelayURLs(ctx, utils.SplitCSV(flags.relayCSV), flags.discovery)
104 + if err != nil {
105 + return fmt.Errorf("resolve relay urls: %w", err)
106 + }
107 +
108 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
104 - RelayURLs: utils.SplitCSV(flags.relayCSV),
109 + RelayURLs: relayURLs,
110 IdentityPath: flags.identityPath,
111 IdentityJSON: flags.identityJSON,
112 Name: flags.name,
@@ -111,7 +116,6 @@ func runExposeCommand(args []string) error {
116 TCPEnabled: flags.tcp,
117 BanMITM: flags.banMITM,
118 MaxActiveRelays: flags.maxActiveRelays,
114 - Discovery: flags.discovery,
119 Metadata: types.LeaseMetadata{
120 Description: flags.desc,
121 Tags: utils.SplitCSV(flags.tags),
portal/api_server.go
+73 -1
@@ -119,6 +119,12 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
119 return
120 }
121 s.handleRelayDiscovery(w, r)
122 + case types.PathDiscoveryAnnounce:
123 + if !s.cfg.DiscoveryEnabled {
124 + base.ServeHTTP(w, r)
125 + return
126 + }
127 + s.handleRelayDiscoveryAnnounce(w, r)
128 case types.PathV1Sign:
129 if keylessSignerHandler == nil {
130 http.NotFound(w, r)
@@ -195,7 +201,73 @@ func (s *Server) handleRelayDiscovery(w http.ResponseWriter, r *http.Request) {
201 utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
202 return
203 }
198 - s.relaySet.ServeDiscovery(w, r, self)
204 + signedSelf, err := discovery.SignDescriptor(self, s.identity.PrivateKey)
205 + if err != nil {
206 + utils.WriteAPIError(w, http.StatusInternalServerError, types.APIErrorCodeInternal, err.Error())
207 + return
208 + }
209 + s.relaySet.ServeDiscovery(w, r, signedSelf)
210 +}
211 +
212 +func (s *Server) handleRelayDiscoveryAnnounce(w http.ResponseWriter, r *http.Request) {
213 + if !utils.RequireMethod(w, r, http.MethodPost) {
214 + return
215 + }
216 + if s.relaySet == nil {
217 + utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, "relay discovery disabled")
218 + return
219 + }
220 + clientIP, ok := s.extractAllowedClientIP(w, r)
221 + if !ok {
222 + return
223 + }
224 + if !s.announceLimiter.Allow(clientIP) {
225 + utils.WriteAPIError(w, http.StatusTooManyRequests, types.APIErrorCodeRateLimited, "announce rate limit exceeded")
226 + return
227 + }
228 +
229 + req, ok := utils.DecodeJSONRequest[types.DiscoveryAnnounceRequest](w, r, defaultControlBodyLimit)
230 + if !ok {
231 + return
232 + }
233 + if req.ProtocolVersion != "" && req.ProtocolVersion != types.DiscoveryVersion {
234 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest,
235 + fmt.Sprintf("announce protocol mismatch: relay=%q client=%q", types.DiscoveryVersion, req.ProtocolVersion))
236 + return
237 + }
238 +
239 + desc := req.Descriptor
240 + // Self-announce guard: the relay's own URL is established locally, not
241 + // gossiped through the announce endpoint. Reject loopback / own-host
242 + // announces to prevent self-amplification or misconfiguration loops.
243 + announceURL, parseErr := url.Parse(desc.APIHTTPSAddr)
244 + if parseErr == nil && announceURL != nil {
245 + if utils.IsLocalRelayHost(announceURL.Hostname()) {
246 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "self-announce rejected")
247 + return
248 + }
249 + }
250 +
251 + now := time.Now().UTC()
252 + accepted, _, err := s.relaySet.InsertAnnounced(desc, now)
253 + if err != nil {
254 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
255 + return
256 + }
257 + if !accepted {
258 + utils.WriteAPIError(w, http.StatusConflict, types.APIErrorCodeInvalidRequest, "announce not accepted")
259 + return
260 + }
261 +
262 + log.Info().
263 + Str("relay", desc.APIHTTPSAddr).
264 + Str("source_ip", clientIP).
265 + Msg("relay discovery announce accepted")
266 +
267 + utils.WriteAPIData(w, http.StatusAccepted, types.DiscoveryAnnounceResponse{
268 + ProtocolVersion: types.DiscoveryVersion,
269 + Accepted: true,
270 + })
271 }
272
273 func (s *Server) handleDomain(w http.ResponseWriter, r *http.Request) {
portal/discovery/announce.go new
+144
@@ -0,0 +1,144 @@
1 +package discovery
2 +
3 +import (
4 + "strings"
5 + "sync"
6 + "time"
7 +)
8 +
9 +// Default per-source-IP rate-limit parameters for the announce endpoint.
10 +// Honest relays announce roughly once per discovery TTL (every five minutes
11 +// at most), so a sustained rate of one request per minute with a small
12 +// burst is generous yet defends against memory-exhaustion injection spam.
13 +const (
14 + DefaultAnnounceRatePerMinute = 5
15 + DefaultAnnounceBurst = 10
16 + announceLimiterPruneInterval = 10 * time.Minute
17 + announceLimiterIdleTTL = 30 * time.Minute
18 +)
19 +
20 +// AnnounceLimiter is a fixed-window-with-token-refill rate limiter keyed
21 +// by source IP. Buckets that have not been touched for announceLimiterIdleTTL
22 +// are garbage-collected on demand to keep memory bounded under a churning
23 +// IP address space.
24 +//
25 +// AnnounceLimiter is safe for concurrent use. All state lives behind a
26 +// single sync.Mutex; the limiter is on the announce hot path but each
27 +// permit decision is O(1) (bucket lookup + arithmetic), and pruning is
28 +// amortized over normal traffic so the worst-case latency is bounded.
29 +type AnnounceLimiter struct {
30 + mu sync.Mutex
31 + buckets map[string]*announceBucket
32 + ratePerMinute float64
33 + burst float64
34 + lastPrune time.Time
35 + pruneInterval time.Duration
36 + bucketIdleTTL time.Duration
37 + clock func() time.Time // overridable for tests
38 + maxBucketCount int
39 +}
40 +
41 +type announceBucket struct {
42 + tokens float64
43 + updatedAt time.Time
44 + lastUsedAt time.Time
45 +}
46 +
47 +// NewAnnounceLimiter constructs a limiter with the supplied sustained rate
48 +// (requests per minute) and burst capacity. Non-positive values fall back
49 +// to the defaults so the zero-config path is safe.
50 +func NewAnnounceLimiter(ratePerMinute, burst int) *AnnounceLimiter {
51 + if ratePerMinute <= 0 {
52 + ratePerMinute = DefaultAnnounceRatePerMinute
53 + }
54 + if burst <= 0 {
55 + burst = DefaultAnnounceBurst
56 + }
57 + return &AnnounceLimiter{
58 + buckets: make(map[string]*announceBucket),
59 + ratePerMinute: float64(ratePerMinute),
60 + burst: float64(burst),
61 + pruneInterval: announceLimiterPruneInterval,
62 + bucketIdleTTL: announceLimiterIdleTTL,
63 + clock: func() time.Time { return time.Now() },
64 + maxBucketCount: 65536,
65 + }
66 +}
67 +
68 +// Allow returns true if the supplied source IP has remaining capacity in
69 +// its bucket and atomically deducts one token. Empty source IPs share a
70 +// single anonymized bucket so a misconfigured proxy cannot bypass the
71 +// limiter by suppressing client identification.
72 +func (l *AnnounceLimiter) Allow(srcIP string) bool {
73 + if l == nil {
74 + return true
75 + }
76 + key := normalizeAnnounceLimiterKey(srcIP)
77 +
78 + l.mu.Lock()
79 + defer l.mu.Unlock()
80 +
81 + now := l.clock()
82 + l.maybePruneLocked(now)
83 +
84 + bucket, ok := l.buckets[key]
85 + if !ok {
86 + // New buckets start full so a single legitimate announce isn't
87 + // gated by warm-up latency.
88 + bucket = &announceBucket{
89 + tokens: l.burst,
90 + updatedAt: now,
91 + lastUsedAt: now,
92 + }
93 + // Hard ceiling: if the table is saturated, refuse new IPs rather
94 + // than allow unbounded growth from random source addresses.
95 + if len(l.buckets) >= l.maxBucketCount {
96 + return false
97 + }
98 + l.buckets[key] = bucket
99 + } else {
100 + elapsed := now.Sub(bucket.updatedAt)
101 + if elapsed > 0 {
102 + bucket.tokens += (l.ratePerMinute * float64(elapsed)) / float64(time.Minute)
103 + if bucket.tokens > l.burst {
104 + bucket.tokens = l.burst
105 + }
106 + bucket.updatedAt = now
107 + }
108 + }
109 +
110 + bucket.lastUsedAt = now
111 + if bucket.tokens < 1 {
112 + return false
113 + }
114 + bucket.tokens--
115 + return true
116 +}
117 +
118 +// maybePruneLocked drops idle buckets so the limiter's memory footprint
119 +// stays proportional to the number of recently-active source IPs. The
120 +// caller MUST already hold l.mu.
121 +func (l *AnnounceLimiter) maybePruneLocked(now time.Time) {
122 + if l.lastPrune.IsZero() {
123 + l.lastPrune = now
124 + return
125 + }
126 + if now.Sub(l.lastPrune) < l.pruneInterval {
127 + return
128 + }
129 + l.lastPrune = now
130 + threshold := now.Add(-l.bucketIdleTTL)
131 + for key, bucket := range l.buckets {
132 + if bucket.lastUsedAt.Before(threshold) {
133 + delete(l.buckets, key)
134 + }
135 + }
136 +}
137 +
138 +func normalizeAnnounceLimiterKey(raw string) string {
139 + key := strings.TrimSpace(raw)
140 + if key == "" {
141 + return "<unknown>"
142 + }
143 + return strings.ToLower(key)
144 +}
portal/discovery/announce_adversarial_test.go new
+122
@@ -0,0 +1,122 @@
1 +package discovery
2 +
3 +import (
4 + "testing"
5 + "time"
6 +
7 + "github.com/gosuda/portal-tunnel/v2/types"
8 +)
9 +
10 +// TestLRUEvictionLeaksRollbackHistoryForSameIdentity exercises a replay-via-LRU
11 +// attack. The rollback defense in upsertDescriptorLocked is anchored in
12 +// s.keyIndex, which records the newest IssuedAt ever accepted for a signing
13 +// identity. deleteRelayLocked drops the keyIndex entry as soon as the last
14 +// URL slot for that identity is evicted — so if the legitimate relay's slot
15 +// is pushed out by LRU pressure, the rollback history is lost.
16 +//
17 +// An attacker who captured an older (but still unexpired) signed descriptor
18 +// can then re-announce it and the receiving relay will accept it as if it had
19 +// never seen any newer IssuedAt. That is strictly contrary to the
20 +// "monotonic IssuedAt per signing identity" invariant documented at
21 +// relayset.go on upsertDescriptorLocked.
22 +//
23 +// This test must FAIL on a correct implementation (i.e. InsertAnnounced must
24 +// reject the older descriptor), and PASS (in the failing-assertion sense)
25 +// on the current implementation — surfacing the bug.
26 +func TestLRUEvictionLeaksRollbackHistoryForSameIdentity(t *testing.T) {
27 + set, err := NewRelaySet(nil)
28 + if err != nil {
29 + t.Fatalf("NewRelaySet() error = %v", err)
30 + }
31 + signing := mustSigningIdentity(t)
32 + now := time.Now().UTC().Truncate(time.Microsecond)
33 + relayURL := "https://relay-replay.example"
34 +
35 + // Step 1: legitimate victim announces the NEWER descriptor. This populates
36 + // s.keyIndex[address] = t1.
37 + t1 := now
38 + t0 := now.Add(-2 * time.Minute) // strictly older but ExpiresAt is still in the future
39 + newer := mustSignedDescriptor(t, signing, "relay-replay", relayURL, t1)
40 + if accepted, _, err := set.InsertAnnounced(newer, now); err != nil || !accepted {
41 + t.Fatalf("seed announce: accepted=%v err=%v", accepted, err)
42 + }
43 +
44 + // Step 2: LRU pressure evicts the victim's slot. In production this
45 + // happens when MaxAnnouncedRelays is exceeded and the victim is the
46 + // oldest non-pinned candidate. We drive it through the same code path
47 + // enforceCapLocked uses so the assertion is faithful.
48 + set.mu.Lock()
49 + set.deleteRelayLocked(relayURL)
50 + set.mu.Unlock()
51 +
52 + // Step 3: attacker replays a strictly OLDER captured descriptor for the
53 + // same signing identity. Rollback defense MUST still reject it — the
54 + // rollback invariant is about the signing identity, not the URL slot.
55 + older := mustSignedDescriptor(t, signing, "relay-replay", relayURL, t0)
56 + accepted, _, err := set.InsertAnnounced(older, now)
57 + if accepted || err == nil {
58 + t.Fatalf(
59 + "rollback history was lost after LRU eviction: "+
60 + "InsertAnnounced(older) accepted=%v err=%v; "+
61 + "keyIndex must persist rollback history across URL-slot eviction",
62 + accepted, err,
63 + )
64 + }
65 +}
66 +
67 +// TestEnforceCapLockedSilentOverflowWhenEveryEntryPinned exercises the cap
68 +// invariant under adversarial pinning. MaxAnnouncedRelays is documented at
69 +// relaystate.go as a "hard ceiling" on the number of relay entries. The
70 +// eviction implementation only considers non-Bootstrap, non-Confirmed
71 +// entries as candidates — if every entry in the set is pinned, the candidate
72 +// list is empty, the eviction loop is a no-op, and the map silently grows
73 +// past the documented ceiling.
74 +//
75 +// This can be reached in practice when an operator bootstrap list grows past
76 +// MaxAnnouncedRelays, or when a listener confirms more relays than the cap
77 +// over a long-running session. Either way the invariant is broken and the
78 +// memory ceiling is not honored.
79 +func TestEnforceCapLockedSilentOverflowWhenEveryEntryPinned(t *testing.T) {
80 + set, err := NewRelaySet(nil)
81 + if err != nil {
82 + t.Fatalf("NewRelaySet() error = %v", err)
83 + }
84 + signing := mustSigningIdentity(t)
85 + base := time.Now().UTC().Truncate(time.Microsecond)
86 +
87 + // Populate MaxAnnouncedRelays + overflow entries, all marked Confirmed.
88 + // A correct eviction policy MUST still maintain the documented hard
89 + // ceiling; a silently-overflowing policy violates it.
90 + const overflow = 3
91 + set.mu.Lock()
92 + for i := range MaxAnnouncedRelays + overflow {
93 + url := "https://pinned-" + sprintInt(i) + ".example"
94 + set.relays[url] = RelayState{
95 + Descriptor: types.RelayDescriptor{
96 + Identity: types.Identity{Address: signing.Address},
97 + APIHTTPSAddr: url,
98 + IssuedAt: base.Add(time.Duration(i) * time.Second),
99 + ExpiresAt: base.Add(time.Hour),
100 + },
101 + Confirmed: true,
102 + LastSeenAt: base.Add(time.Duration(i) * time.Second),
103 + }
104 + }
105 + pre := len(set.relays)
106 + set.enforceCapLocked()
107 + post := len(set.relays)
108 + set.mu.Unlock()
109 +
110 + if pre <= MaxAnnouncedRelays {
111 + t.Fatalf("test setup invalid: pre=%d cap=%d", pre, MaxAnnouncedRelays)
112 + }
113 + if post > MaxAnnouncedRelays {
114 + t.Fatalf(
115 + "enforceCapLocked silently overflowed the hard ceiling: "+
116 + "post=%d cap=%d — when every entry is pinned, the eviction "+
117 + "loop has zero candidates and the set is left above "+
118 + "MaxAnnouncedRelays, violating the documented invariant",
119 + post, MaxAnnouncedRelays,
120 + )
121 + }
122 +}
portal/discovery/announce_test.go new
+260
@@ -0,0 +1,260 @@
1 +package discovery
2 +
3 +import (
4 + "testing"
5 + "time"
6 +
7 + "github.com/gosuda/portal-tunnel/v2/types"
8 + "github.com/gosuda/portal-tunnel/v2/utils"
9 +)
10 +
11 +func mustSignedDescriptor(t *testing.T, signing types.Identity, relayName, relayURL string, issuedAt time.Time) types.RelayDescriptor {
12 + t.Helper()
13 + desc, err := utils.NormalizeDescriptor(types.RelayDescriptor{
14 + Identity: types.Identity{
15 + Name: relayName,
16 + Address: signing.Address,
17 + },
18 + RelayID: relayURL,
19 + Version: 1,
20 + IssuedAt: issuedAt,
21 + ExpiresAt: issuedAt.Add(DiscoveryDescriptorTTL),
22 + APIHTTPSAddr: relayURL,
23 + Discovery: true,
24 + })
25 + if err != nil {
26 + t.Fatalf("NormalizeDescriptor() error = %v", err)
27 + }
28 + signed, err := SignDescriptor(desc, signing.PrivateKey)
29 + if err != nil {
30 + t.Fatalf("SignDescriptor() error = %v", err)
31 + }
32 + return signed
33 +}
34 +
35 +func TestInsertAnnouncedAcceptsValidDescriptor(t *testing.T) {
36 + set, err := NewRelaySet(nil)
37 + if err != nil {
38 + t.Fatalf("NewRelaySet() error = %v", err)
39 + }
40 + signing := mustSigningIdentity(t)
41 + now := time.Now().UTC().Truncate(time.Microsecond)
42 + desc := mustSignedDescriptor(t, signing, "relay-ann", "https://relay-ann.example", now)
43 + accepted, changed, err := set.InsertAnnounced(desc, now)
44 + if err != nil {
45 + t.Fatalf("InsertAnnounced() error = %v", err)
46 + }
47 + if !accepted || !changed {
48 + t.Fatalf("expected accept+change, got accepted=%v changed=%v", accepted, changed)
49 + }
50 + if got := set.AggregateRelays(); len(got) != 1 {
51 + t.Fatalf("len(AggregateRelays()) = %d, want 1", len(got))
52 + }
53 +}
54 +
55 +func TestInsertAnnouncedRejectsUnsigned(t *testing.T) {
56 + set, err := NewRelaySet(nil)
57 + if err != nil {
58 + t.Fatalf("NewRelaySet() error = %v", err)
59 + }
60 + signing := mustSigningIdentity(t)
61 + now := time.Now().UTC().Truncate(time.Microsecond)
62 + desc := mustNormalizedDescriptor(t, signing, "relay-unsigned", "https://relay-unsigned.example")
63 + if accepted, _, err := set.InsertAnnounced(desc, now); accepted || err == nil {
64 + t.Fatalf("expected unsigned reject, got accepted=%v err=%v", accepted, err)
65 + }
66 +}
67 +
68 +func TestInsertAnnouncedRejectsExpired(t *testing.T) {
69 + set, err := NewRelaySet(nil)
70 + if err != nil {
71 + t.Fatalf("NewRelaySet() error = %v", err)
72 + }
73 + signing := mustSigningIdentity(t)
74 + now := time.Now().UTC().Truncate(time.Microsecond)
75 + stale := now.Add(-2 * DiscoveryDescriptorTTL)
76 + desc := mustSignedDescriptor(t, signing, "relay-stale", "https://relay-stale.example", stale)
77 + if accepted, _, err := set.InsertAnnounced(desc, now); accepted || err == nil {
78 + t.Fatalf("expected expired reject, got accepted=%v err=%v", accepted, err)
79 + }
80 +}
81 +
82 +func TestInsertAnnouncedRejectsFutureClockSkew(t *testing.T) {
83 + set, err := NewRelaySet(nil)
84 + if err != nil {
85 + t.Fatalf("NewRelaySet() error = %v", err)
86 + }
87 + signing := mustSigningIdentity(t)
88 + now := time.Now().UTC().Truncate(time.Microsecond)
89 + future := now.Add(2 * AnnounceClockSkewTolerance)
90 + desc := mustSignedDescriptor(t, signing, "relay-future", "https://relay-future.example", future)
91 + if accepted, _, err := set.InsertAnnounced(desc, now); accepted || err == nil {
92 + t.Fatalf("expected future-skew reject, got accepted=%v err=%v", accepted, err)
93 + }
94 +}
95 +
96 +func TestInsertAnnouncedRejectsRollback(t *testing.T) {
97 + set, err := NewRelaySet(nil)
98 + if err != nil {
99 + t.Fatalf("NewRelaySet() error = %v", err)
100 + }
101 + signing := mustSigningIdentity(t)
102 + now := time.Now().UTC().Truncate(time.Microsecond)
103 + relayURL := "https://relay-roll.example"
104 + newer := mustSignedDescriptor(t, signing, "relay-roll", relayURL, now)
105 + if _, _, err := set.InsertAnnounced(newer, now); err != nil {
106 + t.Fatalf("seed insert error = %v", err)
107 + }
108 + older := mustSignedDescriptor(t, signing, "relay-roll", relayURL, now.Add(-time.Minute))
109 + if accepted, _, err := set.InsertAnnounced(older, now); accepted || err == nil {
110 + t.Fatalf("expected rollback reject, got accepted=%v err=%v", accepted, err)
111 + }
112 +}
113 +
114 +func TestInsertAnnouncedBlocksCrossIdentityTakeover(t *testing.T) {
115 + set, err := NewRelaySet(nil)
116 + if err != nil {
117 + t.Fatalf("NewRelaySet() error = %v", err)
118 + }
119 + owner := mustSigningIdentity(t)
120 + attacker := mustSigningIdentity(t)
121 + now := time.Now().UTC().Truncate(time.Microsecond)
122 + relayURL := "https://relay-takeover.example"
123 +
124 + ownerDesc := mustSignedDescriptor(t, owner, "relay-takeover", relayURL, now)
125 + if _, _, err := set.InsertAnnounced(ownerDesc, now); err != nil {
126 + t.Fatalf("owner insert error = %v", err)
127 + }
128 +
129 + attackerDesc := mustSignedDescriptor(t, attacker, "relay-takeover", relayURL, now.Add(time.Second))
130 + if accepted, _, err := set.InsertAnnounced(attackerDesc, now); accepted || err == nil {
131 + t.Fatalf("expected takeover reject, got accepted=%v err=%v", accepted, err)
132 + }
133 +
134 + states := set.AggregateRelays()
135 + if len(states) != 1 {
136 + t.Fatalf("len(AggregateRelays()) = %d, want 1", len(states))
137 + }
138 + if got := states[0].Descriptor.Address; got != owner.Address {
139 + t.Fatalf("retained address = %q, want %q", got, owner.Address)
140 + }
141 +}
142 +
143 +func TestEnforceCapLockedEvictsOldestNonPinned(t *testing.T) {
144 + set, err := NewRelaySet([]string{"https://bootstrap.example"})
145 + if err != nil {
146 + t.Fatalf("NewRelaySet() error = %v", err)
147 + }
148 + signing := mustSigningIdentity(t)
149 + base := time.Now().UTC().Truncate(time.Microsecond)
150 +
151 + // Inject MaxAnnouncedRelays + extra candidates so eviction must run.
152 + // LastSeenAt is a strict ramp so we can assert exactly which slots
153 + // the oldest-first policy removed.
154 + const extra = 5
155 + set.mu.Lock()
156 + for i := range MaxAnnouncedRelays + extra {
157 + url := "https://stub-" + sprintInt(i) + ".example"
158 + set.relays[url] = RelayState{
159 + Descriptor: types.RelayDescriptor{
160 + Identity: types.Identity{Address: signing.Address},
161 + APIHTTPSAddr: url,
162 + IssuedAt: base.Add(time.Duration(i) * time.Second),
163 + ExpiresAt: base.Add(time.Hour),
164 + },
165 + LastSeenAt: base.Add(time.Duration(i) * time.Second),
166 + }
167 + }
168 + // Pin one of the candidates as Confirmed at index 1 so we can assert
169 + // that pinned entries are NOT evicted even when they are old.
170 + pinnedURL := "https://stub-1.example"
171 + pinned := set.relays[pinnedURL]
172 + pinned.Confirmed = true
173 + set.relays[pinnedURL] = pinned
174 +
175 + preCount := len(set.relays)
176 + set.enforceCapLocked()
177 + postCount := len(set.relays)
178 +
179 + _, bootstrapPresent := set.relays["https://bootstrap.example"]
180 + _, pinnedPresent := set.relays[pinnedURL]
181 + // The oldest non-pinned candidates (index 0, then 2, 3, 4 — index 1 is
182 + // pinned) should have been evicted first. extra+1 entries are removed
183 + // because the bootstrap slot pushes total over the cap by one.
184 + _, oldest0Present := set.relays["https://stub-0.example"]
185 + _, oldest2Present := set.relays["https://stub-2.example"]
186 + _, newestPresent := set.relays["https://stub-"+sprintInt(MaxAnnouncedRelays+extra-1)+".example"]
187 + set.mu.Unlock()
188 +
189 + if preCount <= MaxAnnouncedRelays {
190 + t.Fatalf("test setup invalid: preCount=%d cap=%d", preCount, MaxAnnouncedRelays)
191 + }
192 + if postCount > MaxAnnouncedRelays {
193 + t.Fatalf("postCount=%d exceeds cap=%d", postCount, MaxAnnouncedRelays)
194 + }
195 + if !bootstrapPresent {
196 + t.Fatal("bootstrap entry must survive LRU eviction")
197 + }
198 + if !pinnedPresent {
199 + t.Fatal("Confirmed entry must survive LRU eviction even if old")
200 + }
201 + if oldest0Present {
202 + t.Fatal("oldest non-pinned entry (stub-0) must be evicted first")
203 + }
204 + if oldest2Present {
205 + t.Fatal("second-oldest non-pinned entry (stub-2) must also be evicted")
206 + }
207 + if !newestPresent {
208 + t.Fatal("newest entry must survive LRU eviction")
209 + }
210 +}
211 +
212 +func sprintInt(n int) string {
213 + if n == 0 {
214 + return "0"
215 + }
216 + digits := make([]byte, 0, 6)
217 + negative := n < 0
218 + if negative {
219 + n = -n
220 + }
221 + for n > 0 {
222 + digits = append([]byte{byte('0' + n%10)}, digits...)
223 + n /= 10
224 + }
225 + if negative {
226 + return "-" + string(digits)
227 + }
228 + return string(digits)
229 +}
230 +
231 +func TestAnnounceLimiterAllowsBurstThenThrottles(t *testing.T) {
232 + limiter := NewAnnounceLimiter(60, 5) // 1/sec sustained, burst 5
233 + for i := range 5 {
234 + if !limiter.Allow("10.0.0.1") {
235 + t.Fatalf("burst[%d] should be allowed", i)
236 + }
237 + }
238 + if limiter.Allow("10.0.0.1") {
239 + t.Fatal("burst budget should be exhausted")
240 + }
241 + if !limiter.Allow("10.0.0.2") {
242 + t.Fatal("different IP should have its own bucket")
243 + }
244 +}
245 +
246 +func TestAnnounceLimiterRefillsOverTime(t *testing.T) {
247 + limiter := NewAnnounceLimiter(60, 1) // 1/sec sustained, burst 1
248 + clock := time.Now()
249 + limiter.clock = func() time.Time { return clock }
250 + if !limiter.Allow("10.0.0.1") {
251 + t.Fatal("first request should be allowed")
252 + }
253 + if limiter.Allow("10.0.0.1") {
254 + t.Fatal("second immediate request should be throttled")
255 + }
256 + clock = clock.Add(2 * time.Second)
257 + if !limiter.Allow("10.0.0.1") {
258 + t.Fatal("after refill the request should be allowed again")
259 + }
260 +}
portal/discovery/policy_test.go
+11 -2
@@ -12,10 +12,15 @@ import (
12 func mustPolicyRelayDescriptor(t *testing.T, relayName, relayURL string) types.RelayDescriptor {
13 t.Helper()
14
15 + signing, err := utils.ResolveSecp256k1Identity("")
16 + if err != nil {
17 + t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
18 + }
19 now := time.Now().UTC()
20 desc, err := utils.NormalizeDescriptor(types.RelayDescriptor{
21 Identity: types.Identity{
18 - Name: relayName,
22 + Name: relayName,
23 + Address: signing.Address,
24 },
25 RelayID: relayURL,
26 Version: 1,
@@ -27,7 +32,11 @@ func mustPolicyRelayDescriptor(t *testing.T, relayName, relayURL string) types.R
32 if err != nil {
33 t.Fatalf("NormalizeDescriptor() error = %v", err)
34 }
30 - return desc
35 + signed, err := SignDescriptor(desc, signing.PrivateKey)
36 + if err != nil {
37 + t.Fatalf("SignDescriptor() error = %v", err)
38 + }
39 + return signed
40 }
41
42 func bootstrapPolicyRelayState(relayURL string) RelayState {
portal/discovery/relayset.go
+308 -8
@@ -6,6 +6,7 @@ import (
6 "net/http"
7 "reflect"
8 "sort"
9 + "strings"
10 "sync"
11 "time"
12
@@ -16,16 +17,54 @@ import (
17 // RelaySet owns the shared relay discovery view: configured bootstrap relay URLs,
18 // the latest validated descriptor seen for each relay, and local runtime state
19 // such as ban/failure tracking and observed discovery RTT.
20 +//
21 +// The relays map is keyed by APIHTTPSAddr (URL). The keyIndex map provides a
22 +// reverse lookup from signing identity (the EVM address derived from the
23 +// signing public key, lower-cased) to the most recent IssuedAt we have ever
24 +// accepted for that identity, along with a tombstone TombstoneUntil that
25 +// records how long the rollback anchor must be remembered. The keyIndex is
26 +// the rollback-defense gate: any descriptor whose IssuedAt is strictly older
27 +// than the recorded latest is rejected before reaching s.relays. Tracking by
28 +// signing key (rather than URL) means a single relay rotating its
29 +// APIHTTPSAddr cannot be tricked into accepting a stale rollback simply by
30 +// submitting it under a new URL.
31 +//
32 +// The keyIndex lifetime is deliberately decoupled from s.relays: evicting the
33 +// last URL slot for an identity (via LRU or explicit removal) MUST NOT forget
34 +// the rollback anchor, otherwise a captured older-but-unexpired descriptor
35 +// could be replayed after eviction. Tombstones expire once the replay window
36 +// closes, i.e. once now > IssuedAt + AnnounceMaxValidity — by that time any
37 +// descriptor whose IssuedAt is ≤ the tombstoned value is strictly expired and
38 +// cannot pass the announce validity check regardless.
39 +//
40 +// Both maps must always be read and written under s.mu. Mutators come in two
41 +// flavors: public methods that own the lock end-to-end, and *Locked helpers
42 +// that assume the caller already holds s.mu as a write lock and never re-
43 +// acquire it themselves. This convention prevents nested-locking deadlocks
44 +// (notably from ApplyRelayDiscoveryResponse, which holds the write lock for
45 +// the entire batch).
46 type RelaySet struct {
20 - mu sync.RWMutex
21 - relays map[string]RelayState
22 - policy RelayPolicy
47 + mu sync.RWMutex
48 + relays map[string]RelayState
49 + keyIndex map[string]keyIndexEntry
50 + policy RelayPolicy
51 +}
52 +
53 +// keyIndexEntry records the rollback anchor for a signing identity.
54 +// IssuedAt is the newest descriptor IssuedAt the set has ever accepted
55 +// for this identity. TombstoneUntil is the wall-clock time at which the
56 +// rollback anchor may safely be forgotten — after that point, any
57 +// replayable descriptor with an older IssuedAt is itself expired.
58 +type keyIndexEntry struct {
59 + IssuedAt time.Time
60 + TombstoneUntil time.Time
61 }
62
63 func NewRelaySet(bootstrapRelayURLs []string) (*RelaySet, error) {
64 set := &RelaySet{
27 - relays: make(map[string]RelayState),
28 - policy: DefaultRelayPolicy{},
65 + relays: make(map[string]RelayState),
66 + keyIndex: make(map[string]keyIndexEntry),
67 + policy: DefaultRelayPolicy{},
68 }
69 if err := set.SetBootstrapRelayURLs(bootstrapRelayURLs); err != nil {
70 return nil, err
@@ -33,6 +72,112 @@ func NewRelaySet(bootstrapRelayURLs []string) (*RelaySet, error) {
72 return set, nil
73 }
74
75 +// keyIndexAddress returns the lower-cased EVM address used as the keyIndex
76 +// key for a given relay state. Empty for stub entries that carry no signed
77 +// descriptor (e.g. bootstrap URL placeholders before the first refresh).
78 +func keyIndexAddress(state RelayState) string {
79 + return strings.ToLower(strings.TrimSpace(state.Descriptor.Address))
80 +}
81 +
82 +// upsertDescriptorLocked applies a fully-merged RelayState to s.relays and
83 +// updates the keyIndex. The caller MUST already hold s.mu as a write lock.
84 +//
85 +// The returned bool indicates whether the descriptor was accepted. The
86 +// upsert is rejected when:
87 +//
88 +// 1. The signing identity has previously published a strictly newer
89 +// IssuedAt (rollback defense).
90 +// 2. The URL slot is already held by a DIFFERENT signing identity whose
91 +// descriptor has not yet expired, and `allowCrossIdentityTakeover` is
92 +// false. This blocks third-party gossip/announce from hijacking a URL
93 +// binding established by direct authoritative contact.
94 +//
95 +// `allowCrossIdentityTakeover` MUST be true only when the caller has
96 +// directly contacted the URL and verified the response is signed by the
97 +// announced identity (i.e. authoritative refresh). Gossip propagation and
98 +// the announce endpoint MUST pass false.
99 +//
100 +// Equal IssuedAt values (idempotent re-broadcast) are accepted because the
101 +// only mutation is the merged local telemetry on the existing URL slot,
102 +// which never contradicts the cryptographic identity of the descriptor.
103 +func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allowCrossIdentityTakeover bool) bool {
104 + relayURL := record.Descriptor.APIHTTPSAddr
105 + if relayURL == "" {
106 + return false
107 + }
108 + address := keyIndexAddress(record)
109 + if address != "" {
110 + if prev, ok := s.keyIndex[address]; ok {
111 + // Stale tombstone: no replayable descriptor could still be
112 + // within its validity window, so drop the anchor and accept
113 + // the fresh descriptor as if first-seen.
114 + if !prev.TombstoneUntil.IsZero() && now.After(prev.TombstoneUntil) {
115 + delete(s.keyIndex, address)
116 + } else if record.Descriptor.IssuedAt.Before(prev.IssuedAt) {
117 + return false
118 + }
119 + }
120 + }
121 + if !allowCrossIdentityTakeover {
122 + if existing, ok := s.relays[relayURL]; ok {
123 + existingAddress := keyIndexAddress(existing)
124 + if existingAddress != "" && address != "" && existingAddress != address {
125 + if !existing.Descriptor.ExpiresAt.IsZero() && existing.Descriptor.ExpiresAt.After(now) {
126 + return false
127 + }
128 + }
129 + }
130 + }
131 + s.relays[relayURL] = record
132 + if address != "" {
133 + issuedAt := record.Descriptor.IssuedAt
134 + tombstoneUntil := issuedAt.Add(AnnounceMaxValidity)
135 + if prev, ok := s.keyIndex[address]; ok {
136 + if prev.IssuedAt.After(issuedAt) {
137 + issuedAt = prev.IssuedAt
138 + }
139 + if prev.TombstoneUntil.After(tombstoneUntil) {
140 + tombstoneUntil = prev.TombstoneUntil
141 + }
142 + }
143 + s.keyIndex[address] = keyIndexEntry{
144 + IssuedAt: issuedAt,
145 + TombstoneUntil: tombstoneUntil,
146 + }
147 + }
148 + return true
149 +}
150 +
151 +// deleteRelayLocked removes a URL slot from s.relays. The keyIndex tombstone
152 +// is intentionally NOT dropped here: the rollback anchor must outlive the
153 +// URL slot so that LRU eviction cannot be used as a laundering step for a
154 +// captured older-but-unexpired descriptor from the same signing identity.
155 +// Stale tombstones are swept by pruneKeyIndexLocked, called from
156 +// enforceCapLocked after every insert. The caller MUST already hold s.mu
157 +// as a write lock.
158 +func (s *RelaySet) deleteRelayLocked(relayURL string) {
159 + if _, ok := s.relays[relayURL]; !ok {
160 + return
161 + }
162 + delete(s.relays, relayURL)
163 +}
164 +
165 +// pruneKeyIndexLocked drops keyIndex tombstones whose replay-window has
166 +// closed. A tombstone at `now.After(entry.TombstoneUntil)` cannot gate any
167 +// live descriptor: the oldest replayable descriptor from the same identity
168 +// would itself be expired (since honest announces cap validity at
169 +// AnnounceMaxValidity). Callers MUST already hold s.mu as a write lock.
170 +func (s *RelaySet) pruneKeyIndexLocked(now time.Time) {
171 + for address, entry := range s.keyIndex {
172 + if entry.TombstoneUntil.IsZero() {
173 + continue
174 + }
175 + if now.After(entry.TombstoneUntil) {
176 + delete(s.keyIndex, address)
177 + }
178 + }
179 +}
180 +
181 func (s *RelaySet) SetRelayPolicy(policy RelayPolicy) {
182 if policy == nil {
183 policy = DefaultRelayPolicy{}
@@ -55,7 +200,7 @@ func (s *RelaySet) SetBootstrapRelayURLs(inputs []string) error {
200 _, bootstrap := keep[key]
201 state.Bootstrap = bootstrap
202 if !state.Bootstrap && !state.hasDescriptor() && !state.Banned && state.consecutiveFailures == 0 {
58 - delete(s.relays, key)
203 + s.deleteRelayLocked(key)
204 continue
205 }
206
@@ -249,6 +394,14 @@ func (s *RelaySet) ApplyRelayDiscoveryResponse(targetURL string, resp types.Disc
394 discoveredOrder := make([]string, 0, len(resp.Relays)+1)
395 targetFound := false
396 add := func(descriptor types.RelayDescriptor) {
397 + // Cryptographic gate: every gossiped descriptor must carry a valid
398 + // signature. Unsigned or invalid-signature descriptors are dropped
399 + // silently — they cannot poison the local relay set, and other peers
400 + // will reach the same verdict independently. This is the sole global
401 + // trust gate under unconditional propagation, so it is mandatory.
402 + if _, verifyErr := VerifyDescriptor(descriptor); verifyErr != nil {
403 + return
404 + }
405 relayState, err := newRelayState(descriptor, now)
406 if err != nil {
407 return
@@ -285,17 +438,33 @@ func (s *RelaySet) ApplyRelayDiscoveryResponse(targetURL string, resp types.Disc
438 record.DiscoveryRTTAt = existingAtURL.DiscoveryRTTAt
439 }
440
288 - if !protocolMismatch && !missingTarget && authoritative && relayURL == targetURL {
441 + isAuthoritativeTarget := !protocolMismatch && !missingTarget && authoritative && relayURL == targetURL
442 + if isAuthoritativeTarget {
443 record.consecutiveFailures = 0
444 record.nextDirectRefreshAt = time.Time{}
445 }
446
293 - s.relays[relayURL] = record
447 + if !s.upsertDescriptorLocked(record, now, isAuthoritativeTarget) {
448 + // The monotonic-IssuedAt check rejected this descriptor as a
449 + // rollback. The cryptographic identity in s.relays is unchanged,
450 + // but if we successfully reached the authoritative target we
451 + // should still credit it as alive on its existing URL slot.
452 + if isAuthoritativeTarget && hasExistingAtURL {
453 + if existingAtURL.consecutiveFailures != 0 || !existingAtURL.nextDirectRefreshAt.IsZero() {
454 + existingAtURL.consecutiveFailures = 0
455 + existingAtURL.nextDirectRefreshAt = time.Time{}
456 + s.relays[relayURL] = existingAtURL
457 + relaySetChanged = true
458 + }
459 + }
460 + continue
461 + }
462
463 if !hasExistingAtURL || !reflect.DeepEqual(existingAtURL, record) {
464 relaySetChanged = true
465 }
466 }
467 + s.enforceCapLocked()
468 if missingTarget {
469 return relaySetChanged, errors.New("target relay descriptor missing from relays")
470 }
@@ -319,6 +488,137 @@ func (s *RelaySet) RecordDiscoveryRTT(relayURL string, rtt time.Duration, measur
488 s.relays[relayURL] = state
489 }
490
491 +// InsertAnnounced ingests a single descriptor submitted via the announce
492 +// endpoint. It is the only public mutator that is intended to be reachable
493 +// from external (untrusted) callers. The full validation pipeline runs
494 +// inline:
495 +//
496 +// 1. The descriptor signature is verified against the recovered public key
497 +// and matched to the descriptor's Address field.
498 +// 2. The descriptor must be currently valid (ExpiresAt strictly in the
499 +// future) and not significantly clock-skewed (IssuedAt no further into
500 +// the future than AnnounceClockSkewTolerance, validity window no longer
501 +// than AnnounceMaxValidity).
502 +// 3. Local merge preserves Bootstrap, Confirmed, Banned, telemetry, and
503 +// direct-refresh retry state from any pre-existing entry at the same URL.
504 +// 4. The shared upsertDescriptorLocked helper enforces the
505 +// monotonic-IssuedAt-per-key rollback guard and the cross-identity
506 +// URL-takeover guard. Announce never grants takeover authority — only
507 +// direct authoritative refresh can do that.
508 +// 5. After a successful upsert, the LRU cap is enforced; bootstrap and
509 +// listener-confirmed entries are pinned.
510 +//
511 +// Returns (accepted, changed, err): accepted=true iff the descriptor was
512 +// stored (or was an idempotent refresh). changed=true iff s.relays was
513 +// mutated. The error categories are exported as Err* sentinels so callers
514 +// can map to HTTP statuses.
515 +func (s *RelaySet) InsertAnnounced(desc types.RelayDescriptor, now time.Time) (accepted bool, changed bool, err error) {
516 + if now.IsZero() {
517 + now = time.Now().UTC()
518 + } else {
519 + now = now.UTC()
520 + }
521 +
522 + if _, verifyErr := VerifyDescriptor(desc); verifyErr != nil {
523 + return false, false, verifyErr
524 + }
525 + normalized, err := utils.NormalizeDescriptor(desc)
526 + if err != nil {
527 + return false, false, fmt.Errorf("normalize announced descriptor: %w", err)
528 + }
529 + if normalized.IssuedAt.IsZero() {
530 + return false, false, errors.New("announced descriptor missing issued_at")
531 + }
532 + if normalized.ExpiresAt.IsZero() {
533 + return false, false, errors.New("announced descriptor missing expires_at")
534 + }
535 + if !normalized.ExpiresAt.After(now) {
536 + return false, false, errors.New("announced descriptor already expired")
537 + }
538 + if normalized.IssuedAt.After(now.Add(AnnounceClockSkewTolerance)) {
539 + return false, false, errors.New("announced descriptor is too far in the future")
540 + }
541 + if normalized.ExpiresAt.Sub(normalized.IssuedAt) > AnnounceMaxValidity {
542 + return false, false, errors.New("announced descriptor validity window exceeds maximum")
543 + }
544 +
545 + record, err := newRelayState(normalized, now)
546 + if err != nil {
547 + return false, false, err
548 + }
549 +
550 + s.mu.Lock()
551 + defer s.mu.Unlock()
552 +
553 + relayURL := record.Descriptor.APIHTTPSAddr
554 + if existing, ok := s.relays[relayURL]; ok {
555 + record.Bootstrap = record.Bootstrap || existing.Bootstrap
556 + record.Confirmed = record.Confirmed || existing.Confirmed
557 + record.Banned = record.Banned || existing.Banned
558 + if record.consecutiveFailures < existing.consecutiveFailures {
559 + record.consecutiveFailures = existing.consecutiveFailures
560 + }
561 + record.nextDirectRefreshAt = existing.nextDirectRefreshAt
562 + if record.DiscoveryRTTAt.IsZero() || (!existing.DiscoveryRTTAt.IsZero() && existing.DiscoveryRTTAt.After(record.DiscoveryRTTAt)) {
563 + record.DiscoveryRTT = existing.DiscoveryRTT
564 + record.DiscoveryRTTAt = existing.DiscoveryRTTAt
565 + }
566 + }
567 +
568 + if !s.upsertDescriptorLocked(record, now, false) {
569 + return false, false, errors.New("announced descriptor rejected by rollback or takeover guard")
570 + }
571 +
572 + s.enforceCapLocked()
573 + return true, true, nil
574 +}
575 +
576 +// enforceCapLocked trims s.relays back to MaxAnnouncedRelays using a
577 +// two-tier eviction strategy: non-Bootstrap non-Confirmed entries are
578 +// evicted first (oldest by LastSeenAt), then non-Bootstrap Confirmed
579 +// entries as a last resort. Bootstrap entries are absolutely pinned —
580 +// an operator misconfig that lists more than MaxAnnouncedRelays bootstraps
581 +// is surfaced by the resulting overflow rather than silently violating
582 +// operator intent. Tombstone keyIndex entries whose replay window has
583 +// closed are swept opportunistically. The caller MUST already hold s.mu
584 +// as a write lock.
585 +func (s *RelaySet) enforceCapLocked() {
586 + s.pruneKeyIndexLocked(time.Now().UTC())
587 + if len(s.relays) <= MaxAnnouncedRelays {
588 + return
589 + }
590 + type ageEntry struct {
591 + url string
592 + confirmed bool
593 + seenAt time.Time
594 + }
595 + candidates := make([]ageEntry, 0, len(s.relays))
596 + for url, state := range s.relays {
597 + if state.Bootstrap {
598 + continue
599 + }
600 + candidates = append(candidates, ageEntry{
601 + url: url,
602 + confirmed: state.Confirmed,
603 + seenAt: state.LastSeenAt,
604 + })
605 + }
606 + sort.Slice(candidates, func(i, j int) bool {
607 + // Non-confirmed entries evict first — confirmed is the last-resort
608 + // tier. Within each tier, oldest LastSeenAt evicts first.
609 + if candidates[i].confirmed != candidates[j].confirmed {
610 + return !candidates[i].confirmed
611 + }
612 + return candidates[i].seenAt.Before(candidates[j].seenAt)
613 + })
614 + for _, c := range candidates {
615 + if len(s.relays) <= MaxAnnouncedRelays {
616 + return
617 + }
618 + s.deleteRelayLocked(c.url)
619 + }
620 +}
621 +
622 func (s *RelaySet) RecordRelayFailure(relayURL string, err error, recoveryFailures int) (backedOff bool, backoffReason string, consecutiveFailures int) {
623 s.mu.Lock()
624 defer s.mu.Unlock()
portal/discovery/relaystate.go
+17
@@ -13,6 +13,23 @@ const (
13 DiscoveryHintRetentionTTL = 30 * 24 * time.Hour
14 defaultDirectRecoveryBackoff = 1 * time.Minute
15 maxDirectRecoveryBackoff = 5 * time.Minute
16 +
17 + // MaxAnnouncedRelays is the hard ceiling on the number of relay entries
18 + // the local set will retain. When exceeded, eviction prefers the oldest
19 + // non-bootstrap, non-confirmed entries by LastSeenAt. Bootstrap and
20 + // listener-confirmed entries are pinned and never evicted by capacity.
21 + MaxAnnouncedRelays = 1024
22 +
23 + // AnnounceClockSkewTolerance bounds how far in the future a descriptor's
24 + // IssuedAt may sit relative to local time. Anything beyond this is
25 + // rejected as clock-skewed or maliciously post-dated.
26 + AnnounceClockSkewTolerance = 5 * time.Minute
27 +
28 + // AnnounceMaxValidity bounds the maximum (ExpiresAt - IssuedAt) window
29 + // for an accepted announce. Honest relays sign with the discovery TTL,
30 + // so a 24h cap leaves ample headroom while preventing attackers from
31 + // minting year-long descriptors.
32 + AnnounceMaxValidity = 24 * time.Hour
33 )
34
35 type RelayState struct {
portal/discovery/sign.go new
+135
@@ -0,0 +1,135 @@
1 +package discovery
2 +
3 +import (
4 + "crypto/sha256"
5 + "encoding/base64"
6 + "encoding/hex"
7 + "errors"
8 + "fmt"
9 + "strings"
10 +
11 + "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
12 +
13 + "github.com/gosuda/portal-tunnel/v2/types"
14 + "github.com/gosuda/portal-tunnel/v2/utils"
15 +)
16 +
17 +// descriptorSignatureSize is the byte length of a recoverable secp256k1
18 +// compact ECDSA signature: 1 byte recovery code + 32 byte R + 32 byte S.
19 +const descriptorSignatureSize = 65
20 +
21 +// ErrDescriptorUnsigned is returned when a descriptor that should carry a
22 +// signature is missing one. Distinct from ErrDescriptorInvalidSignature so
23 +// callers can differentiate "this peer never signed" from "this peer signed
24 +// incorrectly or the payload was tampered with".
25 +var (
26 + ErrDescriptorUnsigned = errors.New("relay descriptor is not signed")
27 + ErrDescriptorInvalidSignature = errors.New("relay descriptor signature is invalid")
28 + ErrDescriptorAddressMismatch = errors.New("relay descriptor address does not match recovered signing key")
29 + ErrDescriptorMissingAddress = errors.New("relay descriptor address is required for signature verification")
30 +)
31 +
32 +// SignDescriptor returns a copy of desc with its Signature field populated by
33 +// signing the canonical bytes with the supplied secp256k1 private key (hex
34 +// encoded). The signature is recoverable, so verifiers do not need to know
35 +// the public key out of band — they recover it from the signature and check
36 +// it derives the descriptor's Address field.
37 +//
38 +// Mutable telemetry fields (Load, LoadScore, LastUpdated) are NOT covered by
39 +// the signature, so callers may freely update them after signing without
40 +// invalidating the signature.
41 +func SignDescriptor(desc types.RelayDescriptor, privateKeyHex string) (types.RelayDescriptor, error) {
42 + privateKey, _, err := utils.ParseSecp256k1PrivateKeyHex(privateKeyHex, true)
43 + if err != nil {
44 + return types.RelayDescriptor{}, fmt.Errorf("relay descriptor signing key: %w", err)
45 + }
46 +
47 + // Strip any pre-existing signature so re-signing is idempotent and the
48 + // canonical bytes never depend on what the signature happens to be.
49 + desc.Signature = ""
50 +
51 + // Both signer and verifier hash the post-normalization form so that
52 + // JSON round-trips and minor input variation (case, whitespace, etc.)
53 + // do not break signature verification.
54 + normalized, err := utils.NormalizeDescriptor(desc)
55 + if err != nil {
56 + return types.RelayDescriptor{}, fmt.Errorf("normalize relay descriptor for signing: %w", err)
57 + }
58 + if strings.TrimSpace(normalized.Address) == "" {
59 + return types.RelayDescriptor{}, ErrDescriptorMissingAddress
60 + }
61 + desc = normalized
62 +
63 + canonical, err := types.CanonicalBytes(desc)
64 + if err != nil {
65 + return types.RelayDescriptor{}, fmt.Errorf("canonicalize relay descriptor: %w", err)
66 + }
67 + digest := sha256.Sum256(canonical)
68 + // isCompressedKey=true matches the descriptor's Identity.PublicKey, which
69 + // is the compressed form of the public key throughout the codebase.
70 + signature := ecdsa.SignCompact(privateKey, digest[:], true)
71 + if len(signature) != descriptorSignatureSize {
72 + return types.RelayDescriptor{}, fmt.Errorf("unexpected compact signature length %d", len(signature))
73 + }
74 +
75 + desc.Signature = base64.StdEncoding.EncodeToString(signature)
76 + return desc, nil
77 +}
78 +
79 +// VerifyDescriptor checks the descriptor's signature against its canonical
80 +// bytes and confirms that the recovered signing key corresponds to the
81 +// descriptor's Address field. Returns the verified compressed public key hex
82 +// on success so callers can use it as a stable identity for indexing.
83 +//
84 +// The descriptor argument is treated as read-only.
85 +func VerifyDescriptor(desc types.RelayDescriptor) (publicKeyHex string, err error) {
86 + rawSignature := strings.TrimSpace(desc.Signature)
87 + if rawSignature == "" {
88 + return "", ErrDescriptorUnsigned
89 + }
90 +
91 + signature, err := base64.StdEncoding.DecodeString(rawSignature)
92 + if err != nil {
93 + return "", fmt.Errorf("%w: base64 decode: %v", ErrDescriptorInvalidSignature, err)
94 + }
95 + if len(signature) != descriptorSignatureSize {
96 + return "", fmt.Errorf("%w: unexpected signature length %d", ErrDescriptorInvalidSignature, len(signature))
97 + }
98 +
99 + // Strip the signature and normalize before recomputing canonical bytes
100 + // so signer and verifier agree on the exact byte sequence that was
101 + // hashed regardless of incidental input variation (case, whitespace,
102 + // JSON ordering of slice elements normalized in NormalizeDescriptor).
103 + unsignedCopy := desc
104 + unsignedCopy.Signature = ""
105 + normalized, err := utils.NormalizeDescriptor(unsignedCopy)
106 + if err != nil {
107 + return "", fmt.Errorf("%w: normalize: %v", ErrDescriptorInvalidSignature, err)
108 + }
109 + if strings.TrimSpace(normalized.Address) == "" {
110 + return "", ErrDescriptorMissingAddress
111 + }
112 + canonical, err := types.CanonicalBytes(normalized)
113 + if err != nil {
114 + return "", fmt.Errorf("canonicalize relay descriptor: %w", err)
115 + }
116 + digest := sha256.Sum256(canonical)
117 +
118 + publicKey, _, err := ecdsa.RecoverCompact(signature, digest[:])
119 + if err != nil {
120 + return "", fmt.Errorf("%w: %v", ErrDescriptorInvalidSignature, err)
121 + }
122 + if publicKey == nil {
123 + return "", ErrDescriptorInvalidSignature
124 + }
125 +
126 + publicKeyHex = hex.EncodeToString(publicKey.SerializeCompressed())
127 + derivedAddress, err := utils.AddressFromCompressedPublicKeyHex(publicKeyHex)
128 + if err != nil {
129 + return "", fmt.Errorf("derive address from recovered key: %w", err)
130 + }
131 + if !strings.EqualFold(strings.TrimSpace(derivedAddress), strings.TrimSpace(normalized.Address)) {
132 + return "", ErrDescriptorAddressMismatch
133 + }
134 + return publicKeyHex, nil
135 +}
portal/discovery/sign_test.go new
+210
@@ -0,0 +1,210 @@
1 +package discovery
2 +
3 +import (
4 + "errors"
5 + "testing"
6 + "time"
7 +
8 + "github.com/gosuda/portal-tunnel/v2/types"
9 + "github.com/gosuda/portal-tunnel/v2/utils"
10 +)
11 +
12 +func mustSigningIdentity(t *testing.T) types.Identity {
13 + t.Helper()
14 + identity, err := utils.ResolveSecp256k1Identity("")
15 + if err != nil {
16 + t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
17 + }
18 + return identity
19 +}
20 +
21 +func mustNormalizedDescriptor(t *testing.T, signing types.Identity, relayName, relayURL string) types.RelayDescriptor {
22 + t.Helper()
23 + now := time.Now().UTC().Truncate(time.Microsecond)
24 + desc, err := utils.NormalizeDescriptor(types.RelayDescriptor{
25 + Identity: types.Identity{
26 + Name: relayName,
27 + Address: signing.Address,
28 + },
29 + RelayID: relayURL,
30 + Version: 1,
31 + IssuedAt: now,
32 + ExpiresAt: now.Add(time.Hour),
33 + APIHTTPSAddr: relayURL,
34 + Discovery: true,
35 + })
36 + if err != nil {
37 + t.Fatalf("NormalizeDescriptor() error = %v", err)
38 + }
39 + return desc
40 +}
41 +
42 +func TestSignDescriptorRoundtrip(t *testing.T) {
43 + signing := mustSigningIdentity(t)
44 + desc := mustNormalizedDescriptor(t, signing, "relay-rt", "https://relay-rt.example")
45 + signed, err := SignDescriptor(desc, signing.PrivateKey)
46 + if err != nil {
47 + t.Fatalf("SignDescriptor() error = %v", err)
48 + }
49 + if signed.Signature == "" {
50 + t.Fatal("signed descriptor must have non-empty Signature")
51 + }
52 + pubKey, err := VerifyDescriptor(signed)
53 + if err != nil {
54 + t.Fatalf("VerifyDescriptor() error = %v", err)
55 + }
56 + if pubKey == "" {
57 + t.Fatal("VerifyDescriptor must return recovered public key")
58 + }
59 +}
60 +
61 +func TestVerifyDescriptorRejectsUnsigned(t *testing.T) {
62 + signing := mustSigningIdentity(t)
63 + desc := mustNormalizedDescriptor(t, signing, "relay-unsigned", "https://relay-unsigned.example")
64 + if _, err := VerifyDescriptor(desc); !errors.Is(err, ErrDescriptorUnsigned) {
65 + t.Fatalf("VerifyDescriptor() err = %v, want ErrDescriptorUnsigned", err)
66 + }
67 +}
68 +
69 +func TestVerifyDescriptorRejectsTamperedSignedField(t *testing.T) {
70 + signing := mustSigningIdentity(t)
71 + desc := mustNormalizedDescriptor(t, signing, "relay-tamper", "https://relay-tamper.example")
72 + signed, err := SignDescriptor(desc, signing.PrivateKey)
73 + if err != nil {
74 + t.Fatalf("SignDescriptor() error = %v", err)
75 + }
76 + tampered := signed
77 + tampered.WireGuardEndpoint = "evil.example:51820"
78 + if _, err := VerifyDescriptor(tampered); err == nil {
79 + t.Fatal("VerifyDescriptor must reject tampered signed field")
80 + }
81 +}
82 +
83 +func TestVerifyDescriptorAcceptsTelemetryUpdate(t *testing.T) {
84 + signing := mustSigningIdentity(t)
85 + desc := mustNormalizedDescriptor(t, signing, "relay-telemetry", "https://relay-telemetry.example")
86 + signed, err := SignDescriptor(desc, signing.PrivateKey)
87 + if err != nil {
88 + t.Fatalf("SignDescriptor() error = %v", err)
89 + }
90 + updated := signed
91 + updated.Load = 42
92 + updated.LoadScore = 99
93 + updated.LastUpdated = time.Now().UnixMilli()
94 + if _, err := VerifyDescriptor(updated); err != nil {
95 + t.Fatalf("VerifyDescriptor() must ignore telemetry, got err = %v", err)
96 + }
97 +}
98 +
99 +func TestVerifyDescriptorRejectsAddressMismatch(t *testing.T) {
100 + signing := mustSigningIdentity(t)
101 + other := mustSigningIdentity(t)
102 + desc := mustNormalizedDescriptor(t, signing, "relay-mismatch", "https://relay-mismatch.example")
103 + // Sign with `signing` but rewrite Address to a different identity.
104 + signed, err := SignDescriptor(desc, signing.PrivateKey)
105 + if err != nil {
106 + t.Fatalf("SignDescriptor() error = %v", err)
107 + }
108 + signed.Address = other.Address
109 + if _, err := VerifyDescriptor(signed); err == nil {
110 + t.Fatal("VerifyDescriptor must reject signing-key/address mismatch")
111 + }
112 +}
113 +
114 +func TestCanonicalBytesDeterministic(t *testing.T) {
115 + signing := mustSigningIdentity(t)
116 + desc := mustNormalizedDescriptor(t, signing, "relay-det", "https://relay-det.example")
117 + first, err := types.CanonicalBytes(desc)
118 + if err != nil {
119 + t.Fatalf("CanonicalBytes() error = %v", err)
120 + }
121 + for i := range 16 {
122 + out, err := types.CanonicalBytes(desc)
123 + if err != nil {
124 + t.Fatalf("CanonicalBytes() error = %v", err)
125 + }
126 + if string(out) != string(first) {
127 + t.Fatalf("CanonicalBytes is not deterministic: iteration %d differs", i)
128 + }
129 + }
130 +}
131 +
132 +func TestApplyRelayDiscoveryResponseRejectsUnsignedDescriptor(t *testing.T) {
133 + set, err := NewRelaySet(nil)
134 + if err != nil {
135 + t.Fatalf("NewRelaySet() error = %v", err)
136 + }
137 + signing := mustSigningIdentity(t)
138 + desc := mustNormalizedDescriptor(t, signing, "relay-strict", "https://relay-strict.example")
139 + // Note: NOT signed.
140 + _, err = set.ApplyRelayDiscoveryResponse("", types.DiscoveryResponse{
141 + ProtocolVersion: types.DiscoveryVersion,
142 + Relays: []types.RelayDescriptor{desc},
143 + }, time.Now().UTC())
144 + if err != nil {
145 + t.Fatalf("ApplyRelayDiscoveryResponse() error = %v", err)
146 + }
147 + if got := set.AggregateRelays(); len(got) != 0 {
148 + t.Fatalf("expected unsigned descriptor to be dropped, got %d relays", len(got))
149 + }
150 +}
151 +
152 +func TestApplyRelayDiscoveryResponseRejectsRollback(t *testing.T) {
153 + set, err := NewRelaySet(nil)
154 + if err != nil {
155 + t.Fatalf("NewRelaySet() error = %v", err)
156 + }
157 + signing := mustSigningIdentity(t)
158 + relayURL := "https://relay-rollback.example"
159 +
160 + now := time.Now().UTC().Truncate(time.Microsecond)
161 + build := func(issuedAt time.Time) types.RelayDescriptor {
162 + desc, err := utils.NormalizeDescriptor(types.RelayDescriptor{
163 + Identity: types.Identity{
164 + Name: "relay-rollback",
165 + Address: signing.Address,
166 + },
167 + RelayID: relayURL,
168 + Version: 1,
169 + IssuedAt: issuedAt,
170 + ExpiresAt: issuedAt.Add(time.Hour),
171 + APIHTTPSAddr: relayURL,
172 + Discovery: true,
173 + })
174 + if err != nil {
175 + t.Fatalf("NormalizeDescriptor() error = %v", err)
176 + }
177 + signed, err := SignDescriptor(desc, signing.PrivateKey)
178 + if err != nil {
179 + t.Fatalf("SignDescriptor() error = %v", err)
180 + }
181 + return signed
182 + }
183 +
184 + newer := build(now)
185 + if _, err := set.ApplyRelayDiscoveryResponse("", types.DiscoveryResponse{
186 + ProtocolVersion: types.DiscoveryVersion,
187 + Relays: []types.RelayDescriptor{newer},
188 + }, now); err != nil {
189 + t.Fatalf("apply newer error = %v", err)
190 + }
191 +
192 + older := build(now.Add(-time.Minute))
193 + changed, err := set.ApplyRelayDiscoveryResponse("", types.DiscoveryResponse{
194 + ProtocolVersion: types.DiscoveryVersion,
195 + Relays: []types.RelayDescriptor{older},
196 + }, now)
197 + if err != nil {
198 + t.Fatalf("apply older error = %v", err)
199 + }
200 + if changed {
201 + t.Fatal("expected rollback descriptor to leave relay set unchanged")
202 + }
203 + states := set.AggregateRelays()
204 + if len(states) != 1 {
205 + t.Fatalf("len(AggregateRelays()) = %d, want 1", len(states))
206 + }
207 + if !states[0].Descriptor.IssuedAt.Equal(newer.IssuedAt) {
208 + t.Fatalf("retained descriptor IssuedAt = %v, want %v", states[0].Descriptor.IssuedAt, newer.IssuedAt)
209 + }
210 +}
portal/server.go
+13 -11
@@ -116,11 +116,12 @@ type Server struct {
116 apiTLSClose io.Closer
117 quicTunnel *quic.Listener
118
119 - overlay *overlay.Overlay
120 - relaySet *discovery.RelaySet
121 - registry *leaseRegistry
122 - udpPorts *transport.PortAllocator
123 - tcpPorts *transport.PortAllocator
119 + overlay *overlay.Overlay
120 + relaySet *discovery.RelaySet
121 + announceLimiter *discovery.AnnounceLimiter
122 + registry *leaseRegistry
123 + udpPorts *transport.PortAllocator
124 + tcpPorts *transport.PortAllocator
125 }
126
127 func NewServer(cfg ServerConfig) (*Server, error) {
@@ -151,12 +152,13 @@ func NewServer(cfg ServerConfig) (*Server, error) {
152 }
153
154 return &Server{
154 - cfg: cfg,
155 - identity: identity,
156 - registry: registry,
157 - relaySet: relaySet,
158 - udpPorts: transport.NewPortAllocator(cfg.MinPort, cfg.MaxPort, 5*time.Minute),
159 - tcpPorts: transport.NewPortAllocator(cfg.MinPort, cfg.MaxPort, 5*time.Minute),
155 + cfg: cfg,
156 + identity: identity,
157 + registry: registry,
158 + relaySet: relaySet,
159 + announceLimiter: discovery.NewAnnounceLimiter(0, 0),
160 + udpPorts: transport.NewPortAllocator(cfg.MinPort, cfg.MaxPort, 5*time.Minute),
161 + tcpPorts: transport.NewPortAllocator(cfg.MinPort, cfg.MaxPort, 5*time.Minute),
162 }, nil
163 }
164
sdk/expose.go
+6 -47
@@ -20,13 +20,17 @@ import (
20 )
21
22 // Exposure owns the lifecycle of one or more relay listeners and accepts
23 -// traffic from all of them through one net.Listener.
23 +// traffic from all of them through one net.Listener. The SDK is a pure
24 +// relay client: it uses only the explicit relay URLs passed in by the
25 +// caller, never gossips its own descriptor into the discovery mesh, and
26 +// never serves the /discovery endpoint. Relay bootstrap expansion (e.g.
27 +// loading a public registry) is the caller's responsibility — resolve the
28 +// final URL list before invoking Expose.
29 type Exposure struct {
30 cancel context.CancelFunc
31 done <-chan struct{}
32
33 identity types.Identity
29 - discovery bool
34 explicitRelays []string
35 TargetAddr string
36 UDPAddr string
@@ -59,7 +63,6 @@ type ExposeConfig struct {
63 TCPEnabled bool
64 BanMITM bool
65 MaxActiveRelays int
62 - Discovery bool
66 Metadata types.LeaseMetadata
67 RootCAPEM []byte
68 }
@@ -72,12 +75,6 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
75 return nil, err
76 }
77 relayURLs := explicitRelayURLs
75 - if cfg.Discovery {
76 - relayURLs, err = utils.ResolvePortalRelayURLs(ctx, explicitRelayURLs, true)
77 - if err != nil {
78 - return nil, err
79 - }
80 - }
78
79 identity, createdIdentity, err := utils.ResolveListenerIdentity(
80 types.Identity{Name: cfg.Name},
@@ -115,7 +112,6 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
112 cancel: cancel,
113 done: exposureCtx.Done(),
114 identity: identity,
118 - discovery: cfg.Discovery,
115 explicitRelays: append([]string(nil), explicitRelayURLs...),
116 TargetAddr: targetAddr,
117 UDPAddr: udpAddr,
@@ -138,9 +134,6 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
134 }
135 }
136
141 - if cfg.Discovery {
142 - go exposure.runDiscoveryLoop(exposureCtx)
143 - }
137 go func() {
138 <-exposure.done
139 _ = exposure.Close()
@@ -149,30 +142,6 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
142 return exposure, nil
143 }
144
152 -func (e *Exposure) runDiscoveryLoop(ctx context.Context) {
153 - refresher, err := discovery.NewRefresher(e.relaySet, e.rootCAPEM, nil, "")
154 - if err != nil {
155 - return
156 - }
157 - ticker := time.NewTicker(discovery.DiscoveryPollInterval)
158 - defer ticker.Stop()
159 -
160 - for {
161 - if err := refresher.Refresh(ctx); err != nil {
162 - return
163 - }
164 - if err := e.reconcileRelayListeners(false); err != nil {
165 - return
166 - }
167 -
168 - select {
169 - case <-ctx.Done():
170 - return
171 - case <-ticker.C:
172 - }
173 - }
174 -}
175 -
145 func (e *Exposure) ActiveRelayURLs() []string {
146 e.listenerMu.RLock()
147 defer e.listenerMu.RUnlock()
@@ -273,16 +242,6 @@ func (e *Exposure) RunHTTP(ctx context.Context, handler http.Handler, localAddr
242 if handler == nil {
243 handler = http.NotFoundHandler()
244 }
276 - if e.discovery {
277 - tmp := handler
278 - handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
279 - if strings.TrimSpace(r.URL.Path) == types.PathDiscovery {
280 - e.relaySet.ServeDiscovery(w, r)
281 - return
282 - }
283 - tmp.ServeHTTP(w, r)
284 - })
285 - }
245
246 e.listenerMu.RLock()
247 hasRelayListeners := len(e.relayListeners) > 0
types/api.go
+10
@@ -94,6 +94,16 @@ type DiscoveryResponse struct {
94 Relays []RelayDescriptor `json:"relays"`
95 }
96
97 +type DiscoveryAnnounceRequest struct {
98 + ProtocolVersion string `json:"protocol_version"`
99 + Descriptor RelayDescriptor `json:"descriptor"`
100 +}
101 +
102 +type DiscoveryAnnounceResponse struct {
103 + ProtocolVersion string `json:"protocol_version"`
104 + Accepted bool `json:"accepted"`
105 +}
106 +
107 type QUICControlMessage struct {
108 AccessToken string `json:"access_token"`
109 }
types/error.go
+1
@@ -18,6 +18,7 @@ const (
18 APIErrorCodeLeaseNotFound = "lease_not_found"
19 APIErrorCodeLeaseRejected = "lease_rejected"
20 APIErrorCodeMethodNotAllowed = "method_not_allowed"
21 + APIErrorCodeRateLimited = "rate_limited"
22 APIErrorCodeSessionCreateFailed = "session_create_failed"
23 APIErrorCodeUnauthorized = "unauthorized"
24 APIErrorCodeUDPPortExhausted = "udp_port_exhausted"
types/identity.go
+65
@@ -1,6 +1,7 @@
1 package types
2
3 import (
4 + "encoding/json"
5 "strings"
6 "time"
7 )
@@ -121,4 +122,68 @@ type RelayDescriptor struct {
122 Load float64 `json:"load,omitempty"`
123 LoadScore float64 `json:"load_score,omitempty"`
124 LastUpdated int64 `json:"last_updated,omitempty"`
125 + Signature string `json:"signature,omitempty"`
126 +}
127 +
128 +// canonicalRelayDescriptor mirrors the subset of RelayDescriptor fields that
129 +// participate in the cryptographic signature. Only fields that uniquely
130 +// identify the relay or affect routing are signed; mutable telemetry (Load,
131 +// LoadScore, LastUpdated) and the Signature itself are deliberately excluded
132 +// so that observers may update telemetry without invalidating the signature.
133 +//
134 +// All slice fields are normalized to non-nil to keep encoding deterministic
135 +// (json.Marshal encodes nil slices as `null` and empty slices as `[]`). Time
136 +// fields are encoded as Unix nanoseconds to avoid any RFC3339 round-trip
137 +// ambiguity.
138 +type canonicalRelayDescriptor struct {
139 + Name string `json:"name"`
140 + Address string `json:"address"`
141 + RelayID string `json:"relay_id"`
142 + OwnerAddress string `json:"owner_address"`
143 + Version uint32 `json:"version"`
144 + IssuedAtUnixNano int64 `json:"issued_at_unix_nano"`
145 + ExpiresAtUnixNano int64 `json:"expires_at_unix_nano"`
146 + APIHTTPSAddr string `json:"api_https_addr"`
147 + IngressTLSAddr string `json:"ingress_tls_addr"`
148 + WireGuardPublicKey string `json:"wireguard_public_key"`
149 + WireGuardEndpoint string `json:"wireguard_endpoint"`
150 + OverlayIPv4 string `json:"overlay_ipv4"`
151 + OverlayCIDRs []string `json:"overlay_cidrs"`
152 + Discovery bool `json:"discovery"`
153 + SupportsUDP bool `json:"supports_udp"`
154 + SupportsTCP bool `json:"supports_tcp"`
155 + SupportsOverlayPeer bool `json:"supports_overlay_peer"`
156 +}
157 +
158 +// CanonicalBytes returns the deterministic byte representation of a relay
159 +// descriptor used for signing and signature verification. Two descriptors
160 +// that differ only in mutable telemetry fields produce identical bytes.
161 +//
162 +// The encoding is JSON over a fixed struct schema (no maps, no omitempty),
163 +// which guarantees field order and presence regardless of input variation.
164 +func CanonicalBytes(desc RelayDescriptor) ([]byte, error) {
165 + overlayCIDRs := desc.OverlayCIDRs
166 + if overlayCIDRs == nil {
167 + overlayCIDRs = []string{}
168 + }
169 + canonical := canonicalRelayDescriptor{
170 + Name: desc.Name,
171 + Address: desc.Address,
172 + RelayID: desc.RelayID,
173 + OwnerAddress: desc.OwnerAddress,
174 + Version: desc.Version,
175 + IssuedAtUnixNano: desc.IssuedAt.UTC().UnixNano(),
176 + ExpiresAtUnixNano: desc.ExpiresAt.UTC().UnixNano(),
177 + APIHTTPSAddr: desc.APIHTTPSAddr,
178 + IngressTLSAddr: desc.IngressTLSAddr,
179 + WireGuardPublicKey: desc.WireGuardPublicKey,
180 + WireGuardEndpoint: desc.WireGuardEndpoint,
181 + OverlayIPv4: desc.OverlayIPv4,
182 + OverlayCIDRs: overlayCIDRs,
183 + Discovery: desc.Discovery,
184 + SupportsUDP: desc.SupportsUDP,
185 + SupportsTCP: desc.SupportsTCP,
186 + SupportsOverlayPeer: desc.SupportsOverlayPeer,
187 + }
188 + return json.Marshal(canonical)
189 }
types/paths.go
+1
@@ -35,4 +35,5 @@ const (
35 PathSDKUnregister = "/sdk/unregister"
36 PathSDKConnect = "/sdk/connect"
37 PathDiscovery = "/discovery"
38 + PathDiscoveryAnnounce = "/discovery/announce"
39 )