6
"net/http"
7
"reflect"
8
"sort"
9
+ "strings"
10
"sync"
11
"time"
12
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
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{}
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
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
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
}
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()