main
go 1,019 lines 30.4 KB
Raw
1 package discovery
2
3 import (
4 "errors"
5 "fmt"
6 "reflect"
7 "slices"
8 "sort"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/rs/zerolog/log"
14
15 "github.com/gosuda/portal-tunnel/v2/portal/auth"
16 "github.com/gosuda/portal-tunnel/v2/portal/telemetry"
17 "github.com/gosuda/portal-tunnel/v2/types"
18 )
19
20 // RelaySet owns the shared relay discovery view: configured bootstrap relay URLs,
21 // the latest validated descriptor seen for each relay, and local runtime state
22 // such as ban/failure tracking and observed discovery RTT.
23 //
24 // The relays map is keyed by APIHTTPSAddr (URL). The keyIndex map provides a
25 // reverse lookup from signing identity (the EVM address derived from the
26 // signing public key, lower-cased) to the most recent IssuedAt we have ever
27 // accepted for that identity, along with a tombstone TombstoneUntil that
28 // records how long the rollback anchor must be remembered. The keyIndex is
29 // the rollback-defense gate: any descriptor whose IssuedAt is strictly older
30 // than the recorded latest is rejected before reaching s.relays. Tracking by
31 // signing key (rather than URL) means a single relay rotating its
32 // APIHTTPSAddr cannot be tricked into accepting a stale rollback simply by
33 // submitting it under a new URL.
34 //
35 // The keyIndex lifetime is deliberately decoupled from s.relays: evicting the
36 // last URL slot for an identity (via LRU or explicit removal) MUST NOT forget
37 // the rollback anchor, otherwise a captured older-but-unexpired descriptor
38 // could be replayed after eviction. Tombstones expire once the replay window
39 // closes, i.e. once now > IssuedAt + AnnounceMaxValidity. By that time any
40 // descriptor whose IssuedAt is at or before the tombstoned value is expired and
41 // cannot pass the announce validity check regardless.
42 //
43 // Both maps must always be read and written under s.mu. Mutators come in two
44 // flavors: public methods that own the lock end-to-end, and *Locked methods
45 // that assume the caller already holds s.mu as a write lock and never re-
46 // acquire it themselves. This convention prevents nested-locking deadlocks
47 // (notably from ApplyRelayDiscoveryResponse, which holds the write lock for
48 // the entire batch).
49 type RelaySet struct {
50 mu sync.RWMutex
51 relays map[string]RelayState
52 keyIndex map[string]keyIndexEntry
53 }
54
55 // keyIndexEntry records the rollback anchor for a signing identity.
56 // IssuedAt is the newest descriptor IssuedAt the set has ever accepted
57 // for this identity. TombstoneUntil is the wall-clock time at which the
58 // rollback anchor may safely be forgotten. After that point, any
59 // replayable descriptor with an older IssuedAt is itself expired.
60 type keyIndexEntry struct {
61 IssuedAt time.Time
62 TombstoneUntil time.Time
63 }
64
65 type upsertResult int
66
67 const (
68 upsertRejected upsertResult = iota
69 upsertAccepted
70 upsertIgnored
71 )
72
73 func NewRelaySet(bootstrapRelayURLs []string) *RelaySet {
74 set := &RelaySet{
75 relays: make(map[string]RelayState),
76 keyIndex: make(map[string]keyIndexEntry),
77 }
78 set.SetBootstrapRelayURLs(bootstrapRelayURLs)
79 return set
80 }
81
82 // currentRelayStates returns a copy of the set after expiring temporary pool bans.
83 func (s *RelaySet) currentRelayStates(now time.Time) []RelayState {
84 if now.IsZero() {
85 now = time.Now().UTC()
86 }
87 s.mu.Lock()
88 defer s.mu.Unlock()
89
90 s.clearExpiredPoolBansLocked(now)
91 states := make([]RelayState, 0, len(s.relays))
92 for _, state := range s.relays {
93 states = append(states, state)
94 }
95 return states
96 }
97
98 // refreshCandidates returns relays worth directly polling after applying local
99 // pool-ban expiry. The refresher owns HTTP; RelaySet owns pool eligibility.
100 func (s *RelaySet) refreshCandidates(now time.Time) []RelayState {
101 if now.IsZero() {
102 now = time.Now().UTC()
103 }
104 states := s.currentRelayStates(now)
105 out := make([]RelayState, 0, len(states))
106 for _, state := range states {
107 if state.Banned {
108 continue
109 }
110 if !state.hasObservedDescriptor() {
111 if !state.Bootstrap {
112 continue
113 }
114 } else if !state.Bootstrap {
115 if !state.nextDiscoveryRefreshAt.IsZero() && state.nextDiscoveryRefreshAt.After(now) {
116 continue
117 }
118 }
119 if state.Descriptor.APIHTTPSAddr == "" {
120 continue
121 }
122 out = append(out, state)
123 }
124 return out
125 }
126
127 func (s *RelaySet) clearExpiredPoolBansLocked(now time.Time) {
128 for relayURL, state := range s.relays {
129 if !state.Banned || state.suppressActiveUntil.IsZero() || state.suppressActiveUntil.After(now) {
130 continue
131 }
132 if !state.Bootstrap {
133 delete(s.relays, relayURL)
134 continue
135 }
136 state.Banned = false
137 state.suppressActiveUntil = time.Time{}
138 state.unhealthySince = time.Time{}
139 s.relays[relayURL] = state
140 }
141 }
142
143 func (s *RelaySet) banFromPoolLocked(relayURL string, now time.Time) {
144 if relayURL == "" {
145 return
146 }
147 bootstrap := false
148 state, ok := s.relays[relayURL]
149 if ok {
150 bootstrap = state.Bootstrap
151 }
152 state = newRelayState(relayURL)
153 state.Bootstrap = bootstrap
154 state.Banned = true
155 state.suppressActiveUntil = now.Add(relayPoolBanTTL)
156 s.relays[relayURL] = state
157 }
158
159 func mergeLocalRelayState(record, existing RelayState) RelayState {
160 record.Bootstrap = record.Bootstrap || existing.Bootstrap
161 record.Confirmed = record.Confirmed || existing.Confirmed
162 record.Banned = record.Banned || existing.Banned
163 if record.discoveryFailures < existing.discoveryFailures {
164 record.discoveryFailures = existing.discoveryFailures
165 }
166 if record.activeFailures < existing.activeFailures {
167 record.activeFailures = existing.activeFailures
168 }
169 record.unhealthySince = existing.unhealthySince
170 record.nextDiscoveryRefreshAt = existing.nextDiscoveryRefreshAt
171 record.suppressActiveUntil = existing.suppressActiveUntil
172 if record.DiscoveryRTTAt.IsZero() || (!existing.DiscoveryRTTAt.IsZero() && existing.DiscoveryRTTAt.After(record.DiscoveryRTTAt)) {
173 record.DiscoveryRTT = existing.DiscoveryRTT
174 record.DiscoveryRTTAt = existing.DiscoveryRTTAt
175 }
176 record.inheritAdaptiveTelemetry(existing)
177 return record
178 }
179
180 func markDiscoveryConfirmed(state RelayState) RelayState {
181 state.discoveryFailures = 0
182 state.nextDiscoveryRefreshAt = time.Time{}
183 state.unhealthySince = time.Time{}
184 return state
185 }
186
187 // upsertDescriptorLocked applies a fully-merged RelayState to s.relays and
188 // updates the keyIndex. The caller MUST already hold s.mu as a write lock.
189 //
190 // The returned status indicates whether the descriptor was accepted, ignored
191 // as an already-superseded same-URL/same-identity announce, or rejected. The
192 // upsert is rejected when:
193 //
194 // 1. The signing identity has previously published a strictly newer
195 // IssuedAt (rollback defense).
196 // 2. The URL slot is already held by a DIFFERENT signing identity whose
197 // descriptor has not yet expired, and `allowCrossIdentityTakeover` is
198 // false. This blocks third-party gossip/announce from hijacking a URL
199 // binding established by direct authoritative contact.
200 //
201 // `allowCrossIdentityTakeover` MUST be true only when the caller has
202 // directly contacted the URL and verified the response is signed by the
203 // announced identity (i.e. authoritative refresh). Gossip propagation and
204 // the announce endpoint MUST pass false.
205 //
206 // Equal IssuedAt values (idempotent re-broadcast) are accepted because the
207 // only mutation is the merged local telemetry on the existing URL slot,
208 // which never contradicts the cryptographic identity of the descriptor.
209 func (s *RelaySet) upsertDescriptorLocked(record RelayState, now time.Time, allowCrossIdentityTakeover bool) upsertResult {
210 relayURL := record.Descriptor.APIHTTPSAddr
211 if relayURL == "" {
212 return upsertRejected
213 }
214 if existing, ok := s.relays[relayURL]; ok && existing.Banned {
215 return upsertRejected
216 }
217 address := strings.ToLower(strings.TrimSpace(record.Descriptor.Address))
218 if address != "" {
219 if prev, ok := s.keyIndex[address]; ok {
220 // Stale tombstone: no replayable descriptor could still be
221 // within its validity window, so drop the anchor and accept
222 // the fresh descriptor as if first-seen.
223 if !prev.TombstoneUntil.IsZero() && now.After(prev.TombstoneUntil) {
224 delete(s.keyIndex, address)
225 } else if record.Descriptor.IssuedAt.Before(prev.IssuedAt) {
226 if existing, ok := s.relays[relayURL]; ok {
227 existingAddress := strings.ToLower(strings.TrimSpace(existing.Descriptor.Address))
228 if existingAddress == address && existing.Descriptor.ExpiresAt.After(now) &&
229 !existing.Descriptor.IssuedAt.Before(record.Descriptor.IssuedAt) {
230 return upsertIgnored
231 }
232 }
233 return upsertRejected
234 }
235 }
236 }
237 if !allowCrossIdentityTakeover {
238 if existing, ok := s.relays[relayURL]; ok {
239 existingAddress := strings.ToLower(strings.TrimSpace(existing.Descriptor.Address))
240 if existingAddress != "" && address != "" && existingAddress != address {
241 if !existing.Descriptor.ExpiresAt.IsZero() && existing.Descriptor.ExpiresAt.After(now) {
242 return upsertRejected
243 }
244 }
245 }
246 }
247 s.relays[relayURL] = record
248 if address != "" {
249 issuedAt := record.Descriptor.IssuedAt
250 tombstoneUntil := issuedAt.Add(AnnounceMaxValidity)
251 if prev, ok := s.keyIndex[address]; ok {
252 if prev.IssuedAt.After(issuedAt) {
253 issuedAt = prev.IssuedAt
254 }
255 if prev.TombstoneUntil.After(tombstoneUntil) {
256 tombstoneUntil = prev.TombstoneUntil
257 }
258 }
259 s.keyIndex[address] = keyIndexEntry{
260 IssuedAt: issuedAt,
261 TombstoneUntil: tombstoneUntil,
262 }
263 }
264 return upsertAccepted
265 }
266
267 func (s *RelaySet) SetBootstrapRelayURLs(inputs []string) {
268 s.mu.Lock()
269 defer s.mu.Unlock()
270
271 now := time.Now().UTC()
272 s.clearExpiredPoolBansLocked(now)
273
274 keep := make(map[string]struct{}, len(inputs))
275 for _, relayURL := range inputs {
276 keep[relayURL] = struct{}{}
277 }
278
279 for key, state := range s.relays {
280 _, bootstrap := keep[key]
281 state.Bootstrap = bootstrap
282 if disposableRelayState(state) {
283 delete(s.relays, key)
284 continue
285 }
286
287 s.relays[key] = state
288 }
289
290 for _, relayURL := range inputs {
291 if state, ok := s.relays[relayURL]; ok {
292 state.Bootstrap = true
293 s.relays[relayURL] = state
294 continue
295 }
296
297 state := newRelayState(relayURL)
298 state.Bootstrap = true
299 s.relays[relayURL] = state
300 }
301 }
302
303 func (s *RelaySet) AddBootstrapRelayURL(relayURL string) {
304 s.mu.Lock()
305 defer s.mu.Unlock()
306
307 state, ok := s.relays[relayURL]
308 if !ok {
309 state = newRelayState(relayURL)
310 }
311 state.Bootstrap = true
312 s.relays[relayURL] = state
313 }
314
315 func (s *RelaySet) RemoveBootstrapRelayURL(relayURL string) {
316 s.mu.Lock()
317 defer s.mu.Unlock()
318
319 state, ok := s.relays[relayURL]
320 if !ok {
321 return
322 }
323 state.Bootstrap = false
324 if disposableRelayState(state) {
325 delete(s.relays, relayURL)
326 return
327 }
328 s.relays[relayURL] = state
329 }
330
331 func disposableRelayState(state RelayState) bool {
332 return !state.Bootstrap && !state.hasObservedDescriptor() && !state.Banned &&
333 state.discoveryFailures == 0 && state.activeFailures == 0 &&
334 state.nextDiscoveryRefreshAt.IsZero() && state.suppressActiveUntil.IsZero()
335 }
336
337 func (s *RelaySet) AggregateRelays() []RelayState {
338 return selectAggregate(s.currentRelayStates(time.Now().UTC()))
339 }
340
341 func (s *RelaySet) AllRelays() []RelayState {
342 return s.currentRelayStates(time.Now().UTC())
343 }
344
345 func (s *RelaySet) ConfirmedRelays() []RelayState {
346 return selectConfirmed(s.currentRelayStates(time.Now().UTC()))
347 }
348
349 type Route struct {
350 path []string
351 explicit bool
352 }
353
354 func NewRoute(path []string, explicit bool) Route {
355 return Route{
356 path: append([]string(nil), path...),
357 explicit: explicit,
358 }
359 }
360
361 func (r Route) Explicit() bool {
362 return r.explicit
363 }
364
365 func (r Route) ListenerRelayURL() string {
366 if len(r.path) == 0 {
367 return ""
368 }
369 return r.path[len(r.path)-1]
370 }
371
372 func (r Route) MultiHop() []string {
373 if len(r.path) <= 1 {
374 return nil
375 }
376 return append([]string(nil), r.path...)
377 }
378
379 func (r Route) Equal(other Route) bool {
380 return r.explicit == other.explicit && slices.Equal(r.path, other.path)
381 }
382
383 func (r Route) WithListenerRelayURL(relayURL string) Route {
384 if len(r.path) == 0 {
385 return NewRoute([]string{relayURL}, r.explicit)
386 }
387 path := append([]string(nil), r.path...)
388 path[len(path)-1] = relayURL
389 return NewRoute(path, r.explicit)
390 }
391
392 func (s *RelaySet) PlanRoutes(explicitPath []string, routeState RouteState) ([]Route, error) {
393 if len(explicitPath) > 0 {
394 if len(explicitPath) == 1 {
395 return nil, fmt.Errorf("multi-hop requires at least entry and exit relay urls")
396 }
397 return []Route{NewRoute(explicitPath, true)}, nil
398 }
399
400 states := s.currentRelayStates(time.Now().UTC())
401 if len(routeState.ExplicitRelayURLs) > 0 {
402 seen := make(map[string]struct{}, len(states))
403 for _, state := range states {
404 if relayURL := strings.TrimSpace(state.Descriptor.APIHTTPSAddr); relayURL != "" {
405 seen[relayURL] = struct{}{}
406 }
407 }
408 for _, relayURL := range routeState.ExplicitRelayURLs {
409 relayURL = strings.TrimSpace(relayURL)
410 if relayURL == "" {
411 continue
412 }
413 if _, ok := seen[relayURL]; ok {
414 continue
415 }
416 states = append(states, newRelayState(relayURL))
417 seen[relayURL] = struct{}{}
418 }
419 }
420
421 if routeState.MultiHopDepth > 1 {
422 path := SelectMultiHop(states, routeState)
423 if len(path) < routeState.MultiHopDepth {
424 return nil, fmt.Errorf("multi-hop-depth %d requires %d overlay relay candidates, got %d", routeState.MultiHopDepth, routeState.MultiHopDepth, len(path))
425 }
426 return []Route{NewRoute(path, false)}, nil
427 }
428
429 relayURLs := SelectPriority(states, routeState)
430 routes := make([]Route, 0, len(relayURLs))
431 for _, relayURL := range relayURLs {
432 routes = append(routes, NewRoute([]string{relayURL}, slices.Contains(routeState.ExplicitRelayURLs, relayURL)))
433 }
434 return routes, nil
435 }
436
437 // PriorityRelaysWithTrace returns the same ordered relay-URL list as
438 // PriorityRelays, plus a SelectionTrace populated with pool statistics,
439 // eligibility classification, and the scoring parameters used. Prometheus
440 // metrics are emitted from the trace before returning, and a sampled zerolog
441 // debug entry is written.
442 func (s *RelaySet) PriorityRelaysWithTrace(routeState RouteState) ([]string, telemetry.SelectionTrace) {
443 states := s.currentRelayStates(time.Now().UTC())
444
445 result, trace := selectPriorityWithTrace(states, routeState)
446 telemetry.EmitFromTrace(trace)
447 log.Debug().
448 Uint8("client_hash", trace.ClientHash).
449 Int("pool_size", trace.PoolTotal).
450 Int("output_count", len(trace.OutputURLs)).
451 Str("mode", trace.Mode).
452 Bool("congested", trace.Congested).
453 Strs("relay_urls", trace.OutputURLs).
454 Msg("relay selection")
455 return result, trace
456 }
457
458 // PriorityRelays returns the ordered list of relay URLs for a client. It
459 // delegates to PriorityRelaysWithTrace and discards the trace.
460 func (s *RelaySet) PriorityRelays(routeState RouteState) []string {
461 out, _ := s.PriorityRelaysWithTrace(routeState)
462 return out
463 }
464
465 // PriorityMultiHopWithTrace returns the same ordered relay-URL list as
466 // PriorityMultiHop, plus a SelectionTrace populated with pool statistics,
467 // eligibility classification, and the scoring parameters used. Prometheus
468 // metrics are emitted from the trace before returning, and a sampled zerolog
469 // debug entry is written.
470 func (s *RelaySet) PriorityMultiHopWithTrace(routeState RouteState) ([]string, telemetry.SelectionTrace) {
471 states := s.currentRelayStates(time.Now().UTC())
472
473 result, trace := selectMultiHopWithTrace(states, routeState)
474 telemetry.EmitFromTrace(trace)
475 log.Debug().
476 Uint8("client_hash", trace.ClientHash).
477 Int("pool_size", trace.PoolTotal).
478 Int("output_count", len(trace.OutputURLs)).
479 Str("mode", trace.Mode).
480 Bool("congested", trace.Congested).
481 Strs("relay_urls", trace.OutputURLs).
482 Msg("relay selection")
483 return result, trace
484 }
485
486 // PriorityMultiHop returns the ordered list of relay URLs for multi-hop
487 // routing. It delegates to PriorityMultiHopWithTrace and discards the trace.
488 func (s *RelaySet) PriorityMultiHop(routeState RouteState) []string {
489 out, _ := s.PriorityMultiHopWithTrace(routeState)
490 return out
491 }
492
493 func (s *RelaySet) overlayRefreshCandidates(now time.Time) []RelayState {
494 if now.IsZero() {
495 now = time.Now().UTC()
496 } else {
497 now = now.UTC()
498 }
499 states := s.overlayPeerRelayStates(now)
500 out := make([]RelayState, 0, len(states))
501 for _, state := range states {
502 if !state.nextDiscoveryRefreshAt.IsZero() && state.nextDiscoveryRefreshAt.After(now) {
503 continue
504 }
505 out = append(out, state)
506 }
507 if len(out) == 0 {
508 return nil
509 }
510 return out
511 }
512
513 func (s *RelaySet) overlayPeerRelayStates(now time.Time) []RelayState {
514 if now.IsZero() {
515 now = time.Now().UTC()
516 } else {
517 now = now.UTC()
518 }
519 states := s.currentRelayStates(now)
520 out := make([]RelayState, 0, len(states))
521 for _, state := range states {
522 if state.Banned || !state.hasObservedDescriptor() || !state.Descriptor.ExpiresAt.After(now) || !state.Descriptor.HasOverlayPeer() {
523 continue
524 }
525 out = append(out, state)
526 }
527 if len(out) == 0 {
528 return nil
529 }
530 return out
531 }
532
533 func (s *RelaySet) OverlayPeerDescriptor() []types.RelayDescriptor {
534 states := s.overlayPeerRelayStates(time.Now().UTC())
535 if len(states) == 0 {
536 return nil
537 }
538 out := make([]types.RelayDescriptor, 0, len(states))
539 for _, state := range states {
540 out = append(out, state.Descriptor)
541 }
542 return out
543 }
544
545 func (s *RelaySet) OverlayRelayDescriptor(relayURL string, now time.Time) (types.RelayDescriptor, bool) {
546 if now.IsZero() {
547 now = time.Now().UTC()
548 } else {
549 now = now.UTC()
550 }
551 relayURL = strings.TrimSpace(relayURL)
552
553 s.mu.RLock()
554 state := s.relays[relayURL]
555 s.mu.RUnlock()
556 if state.Banned || !state.hasObservedDescriptor() || !state.Descriptor.ExpiresAt.After(now) || !state.Descriptor.HasOverlayPeer() {
557 return types.RelayDescriptor{}, false
558 }
559 return state.Descriptor, true
560 }
561
562 // BootstrapRelayURLs returns configured bootstrap discovery endpoints that
563 // can receive this relay's periodic self-announce.
564 func (s *RelaySet) BootstrapRelayURLs() []string {
565 states := s.currentRelayStates(time.Now().UTC())
566 out := make([]string, 0, len(states))
567 for _, state := range states {
568 if state.Banned || !state.Bootstrap {
569 continue
570 }
571 relayURL := strings.TrimSpace(state.Descriptor.APIHTTPSAddr)
572 if relayURL == "" {
573 continue
574 }
575 out = append(out, relayURL)
576 }
577 if len(out) == 0 {
578 return nil
579 }
580 return out
581 }
582
583 func (s *RelaySet) Descriptors(self types.RelayDescriptor) []types.RelayDescriptor {
584 now := time.Now().UTC()
585 out := make([]types.RelayDescriptor, 0, 1)
586 seen := make(map[string]struct{})
587 add := func(desc types.RelayDescriptor) {
588 relayURL := desc.APIHTTPSAddr
589 if relayURL == "" {
590 return
591 }
592 if _, ok := seen[relayURL]; ok {
593 return
594 }
595 if !desc.ExpiresAt.After(now) {
596 return
597 }
598 seen[relayURL] = struct{}{}
599 out = append(out, desc)
600 }
601
602 if self.APIHTTPSAddr != "" && self.ExpiresAt.After(now) {
603 add(self)
604 }
605 for _, state := range s.currentRelayStates(now) {
606 if state.Banned || !state.hasObservedDescriptor() {
607 continue
608 }
609 add(state.Descriptor)
610 }
611 if len(out) == 0 {
612 return nil
613 }
614 return out
615 }
616
617 func (s *RelaySet) BanRelayURL(relayURL string) {
618 s.mu.Lock()
619 defer s.mu.Unlock()
620
621 state, ok := s.relays[relayURL]
622 if !ok {
623 state = newRelayState(relayURL)
624 }
625 state.suppressActiveUntil = time.Time{}
626 state.Banned = true
627 s.relays[relayURL] = state
628 }
629
630 func (s *RelaySet) DropRelayURLFromActivePool(relayURL string) {
631 s.mu.Lock()
632 defer s.mu.Unlock()
633
634 now := time.Now().UTC()
635 s.clearExpiredPoolBansLocked(now)
636 state, ok := s.relays[relayURL]
637 if !ok || state.Banned {
638 return
639 }
640 state.Confirmed = false
641 state.suppressActiveUntil = now.Add(activeDropTTL)
642 s.relays[relayURL] = state
643 }
644
645 func (s *RelaySet) AllowRelayURL(relayURL string) {
646 s.mu.Lock()
647 defer s.mu.Unlock()
648
649 state, ok := s.relays[relayURL]
650 if !ok {
651 state = newRelayState(relayURL)
652 }
653 state.Banned = false
654 state.suppressActiveUntil = time.Time{}
655 state.unhealthySince = time.Time{}
656 s.relays[relayURL] = state
657 }
658
659 func (s *RelaySet) ConfirmRelayURL(relayURL string) {
660 s.mu.Lock()
661 defer s.mu.Unlock()
662
663 now := time.Now().UTC()
664 s.clearExpiredPoolBansLocked(now)
665 state, ok := s.relays[relayURL]
666 if !ok {
667 state = newRelayState(relayURL)
668 }
669 if state.Banned {
670 s.relays[relayURL] = state
671 return
672 }
673 state.Confirmed = true
674 state.activeFailures = 0
675 state.suppressActiveUntil = time.Time{}
676 s.relays[relayURL] = state
677 }
678
679 func (s *RelaySet) UnconfirmRelayURL(relayURL string) {
680 s.mu.Lock()
681 defer s.mu.Unlock()
682
683 state, ok := s.relays[relayURL]
684 if !ok {
685 return
686 }
687 state.Confirmed = false
688 s.relays[relayURL] = state
689 }
690
691 // DeactivateRelayURL drops a relay out of active selection while keeping its
692 // discovered descriptor as a candidate.
693 func (s *RelaySet) DeactivateRelayURL(relayURL string) {
694 s.mu.Lock()
695 defer s.mu.Unlock()
696
697 state, ok := s.relays[relayURL]
698 if !ok {
699 return
700 }
701 state.Confirmed = false
702 state.suppressActiveUntil = time.Now().Add(defaultDirectRecoveryBackoff)
703 s.relays[relayURL] = state
704 }
705
706 func (s *RelaySet) ApplyRelayDiscoveryResponse(targetURL string, resp types.DiscoveryResponse, now time.Time) (relaySetChanged bool, err error) {
707 if now.IsZero() {
708 now = time.Now().UTC()
709 } else {
710 now = now.UTC()
711 }
712 protocolMismatch := resp.ProtocolVersion != types.DiscoveryVersion
713 authoritative := targetURL != ""
714
715 s.mu.Lock()
716 defer s.mu.Unlock()
717 s.clearExpiredPoolBansLocked(now)
718
719 discoveredByURL := make(map[string]RelayState, len(resp.Relays))
720 discoveredOrder := make([]string, 0, len(resp.Relays)+1)
721 targetFound := false
722 add := func(descriptor types.RelayDescriptor) {
723 // Cryptographic gate: every gossiped descriptor must carry a valid
724 // signature. Unsigned or invalid-signature descriptors are dropped
725 // silently; they cannot poison the local relay set, and other peers
726 // will reach the same verdict independently. This is the sole global
727 // trust gate under unconditional propagation, so it is mandatory.
728 verified, verifyErr := auth.VerifyRelayDescriptor(descriptor)
729 if verifyErr != nil {
730 return
731 }
732 if err := validateRelayDescriptorFreshness(verified, now); err != nil {
733 return
734 }
735 relayState := RelayState{
736 Descriptor: verified,
737 LastSeenAt: now,
738 }
739 relayURL := verified.APIHTTPSAddr
740 if relayURL == "" {
741 return
742 }
743 if existing, ok := s.relays[relayURL]; ok && existing.Banned {
744 return
745 }
746 if authoritative && relayURL == targetURL {
747 targetFound = true
748 }
749 if _, ok := discoveredByURL[relayURL]; !ok {
750 discoveredOrder = append(discoveredOrder, relayURL)
751 }
752 discoveredByURL[relayURL] = relayState
753 }
754 for _, descriptor := range resp.Relays {
755 add(descriptor)
756 }
757 missingTarget := authoritative && !targetFound
758
759 for _, relayURL := range discoveredOrder {
760 record := discoveredByURL[relayURL]
761 existingAtURL, hasExistingAtURL := s.relays[relayURL]
762 record = mergeLocalRelayState(record, existingAtURL)
763
764 isAuthoritativeTarget := !protocolMismatch && !missingTarget && authoritative && relayURL == targetURL
765 if isAuthoritativeTarget {
766 record = markDiscoveryConfirmed(record)
767 }
768
769 if upsert := s.upsertDescriptorLocked(record, now, isAuthoritativeTarget); upsert != upsertAccepted {
770 // The monotonic-IssuedAt check rejected this descriptor as a
771 // rollback, or ignored it because a newer same-identity descriptor
772 // for this URL is already present. The cryptographic identity in
773 // s.relays is unchanged, but if we successfully reached the
774 // authoritative target we should still credit it as alive on its
775 // existing URL slot.
776 if isAuthoritativeTarget && hasExistingAtURL {
777 if existingAtURL.discoveryFailures != 0 || !existingAtURL.nextDiscoveryRefreshAt.IsZero() || !existingAtURL.unhealthySince.IsZero() {
778 existingAtURL = markDiscoveryConfirmed(existingAtURL)
779 s.relays[relayURL] = existingAtURL
780 relaySetChanged = true
781 }
782 }
783 continue
784 }
785
786 if !hasExistingAtURL || !reflect.DeepEqual(existingAtURL, record) {
787 relaySetChanged = true
788 }
789 }
790 s.enforceCapLocked()
791 if missingTarget {
792 return relaySetChanged, errors.New("target relay descriptor missing from relays")
793 }
794 if protocolMismatch && authoritative {
795 return relaySetChanged, fmt.Errorf("relay discovery protocol version mismatch: relay=%q client=%q", resp.ProtocolVersion, types.DiscoveryVersion)
796 }
797 return relaySetChanged, nil
798 }
799
800 func (s *RelaySet) RecordDiscoveryRTT(relayURL string, rtt time.Duration, measuredAt time.Time) {
801 s.mu.Lock()
802 defer s.mu.Unlock()
803
804 state, ok := s.relays[relayURL]
805 if !ok {
806 return
807 }
808
809 state.DiscoveryRTT = rtt
810 state.DiscoveryRTTAt = measuredAt
811 s.relays[relayURL] = state
812 }
813
814 func (s *RelaySet) RecordLoadFactor(relayURL string, loadFixed uint32) {
815 s.mu.Lock()
816 defer s.mu.Unlock()
817
818 state, ok := s.relays[relayURL]
819 if !ok {
820 return
821 }
822
823 state.StoreLoadFactor(loadFixed)
824 s.relays[relayURL] = state
825 }
826
827 // InsertAnnounced ingests a single descriptor submitted via the announce
828 // endpoint. It is the only public mutator that is intended to be reachable
829 // from external (untrusted) callers. The full validation pipeline runs
830 // inline:
831 //
832 // 1. The descriptor signature is verified against the recovered public key
833 // and matched to the descriptor's Address field.
834 // 2. The descriptor must be currently valid (ExpiresAt strictly in the
835 // future) and not significantly clock-skewed (IssuedAt no further into
836 // the future than AnnounceClockSkewTolerance, validity window no longer
837 // than AnnounceMaxValidity).
838 // 3. Local merge preserves Bootstrap, Confirmed, Banned, discovery retry
839 // state, active suppression state, and telemetry from any pre-existing
840 // entry at the same URL.
841 // 4. The shared upsertDescriptorLocked method enforces the
842 // monotonic-IssuedAt-per-key rollback guard and the cross-identity
843 // URL-takeover guard. Announce never grants takeover authority; only
844 // direct authoritative refresh can do that.
845 // 5. After a successful upsert, the LRU cap is enforced; bootstrap and
846 // listener-confirmed entries are pinned.
847 //
848 // Returns nil iff the descriptor was stored, idempotently refreshed, or is an
849 // older same-URL/same-identity announce already superseded by local state.
850 func (s *RelaySet) InsertAnnounced(desc types.RelayDescriptor, now time.Time) error {
851 if now.IsZero() {
852 now = time.Now().UTC()
853 } else {
854 now = now.UTC()
855 }
856
857 normalized, err := auth.VerifyRelayDescriptor(desc)
858 if err != nil {
859 return err
860 }
861 if err := validateRelayDescriptorFreshness(normalized, now); err != nil {
862 return err
863 }
864
865 record := RelayState{
866 Descriptor: normalized,
867 LastSeenAt: now,
868 }
869
870 s.mu.Lock()
871 defer s.mu.Unlock()
872 s.clearExpiredPoolBansLocked(now)
873
874 relayURL := record.Descriptor.APIHTTPSAddr
875 if existing, ok := s.relays[relayURL]; ok && existing.Banned {
876 return errors.New("relay banned from pool")
877 }
878 if existing, ok := s.relays[relayURL]; ok {
879 record = mergeLocalRelayState(record, existing)
880 }
881
882 switch s.upsertDescriptorLocked(record, now, false) {
883 case upsertAccepted:
884 s.enforceCapLocked()
885 return nil
886 case upsertIgnored:
887 return nil
888 case upsertRejected:
889 return errors.New("announced descriptor rejected by rollback or takeover guard")
890 }
891 return nil
892 }
893
894 func validateRelayDescriptorFreshness(desc types.RelayDescriptor, now time.Time) error {
895 if desc.IssuedAt.IsZero() {
896 return errors.New("relay descriptor missing issued_at")
897 }
898 if !desc.ExpiresAt.After(now) {
899 return errors.New("relay descriptor already expired")
900 }
901 if desc.IssuedAt.After(now.Add(AnnounceClockSkewTolerance)) {
902 return errors.New("relay descriptor is too far in the future")
903 }
904 if desc.ExpiresAt.Sub(desc.IssuedAt) > AnnounceMaxValidity {
905 return errors.New("relay descriptor validity window exceeds maximum")
906 }
907 return nil
908 }
909
910 // enforceCapLocked trims s.relays back to MaxAnnouncedRelays using a
911 // two-tier eviction strategy: non-Bootstrap non-Confirmed entries are
912 // evicted first (oldest by LastSeenAt), then non-Bootstrap Confirmed
913 // entries as a last resort. Bootstrap entries are absolutely pinned.
914 // An operator misconfig that lists more than MaxAnnouncedRelays bootstraps
915 // is surfaced by the resulting overflow rather than silently violating
916 // operator intent. Tombstone keyIndex entries whose replay window has
917 // closed are swept opportunistically. The caller MUST already hold s.mu
918 // as a write lock.
919 func (s *RelaySet) enforceCapLocked() {
920 now := time.Now().UTC()
921 s.clearExpiredPoolBansLocked(now)
922 for address, entry := range s.keyIndex {
923 if !entry.TombstoneUntil.IsZero() && now.After(entry.TombstoneUntil) {
924 delete(s.keyIndex, address)
925 }
926 }
927 if len(s.relays) <= MaxAnnouncedRelays {
928 return
929 }
930 type ageEntry struct {
931 url string
932 confirmed bool
933 seenAt time.Time
934 }
935 candidates := make([]ageEntry, 0, len(s.relays))
936 for url, state := range s.relays {
937 if state.Bootstrap || state.Banned {
938 continue
939 }
940 candidates = append(candidates, ageEntry{
941 url: url,
942 confirmed: state.Confirmed,
943 seenAt: state.LastSeenAt,
944 })
945 }
946 sort.Slice(candidates, func(i, j int) bool {
947 // Non-confirmed entries evict first; confirmed is the last-resort
948 // tier. Within each tier, oldest LastSeenAt evicts first.
949 if candidates[i].confirmed != candidates[j].confirmed {
950 return !candidates[i].confirmed
951 }
952 return candidates[i].seenAt.Before(candidates[j].seenAt)
953 })
954 for _, c := range candidates {
955 if len(s.relays) <= MaxAnnouncedRelays {
956 return
957 }
958 delete(s.relays, c.url)
959 }
960 }
961
962 func (s *RelaySet) RecordDiscoveryFailure(relayURL string, recoveryFailures int) (backedOff bool, backoffReason string, failureCount int) {
963 s.mu.Lock()
964 defer s.mu.Unlock()
965
966 now := time.Now().UTC()
967 s.clearExpiredPoolBansLocked(now)
968 state, ok := s.relays[relayURL]
969 if !ok || state.Banned {
970 return false, "", 0
971 }
972 state.discoveryFailures++
973 if state.unhealthySince.IsZero() {
974 state.unhealthySince = now
975 }
976 if !state.unhealthySince.Add(AnnounceMaxValidity).After(now) {
977 s.banFromPoolLocked(relayURL, now)
978 return true, "unhealthy", state.discoveryFailures
979 }
980
981 if recoveryFailures <= 0 || state.discoveryFailures < recoveryFailures {
982 s.relays[relayURL] = state
983 return false, "retry", state.discoveryFailures
984 }
985 failuresOverBudget := state.discoveryFailures - recoveryFailures
986 backoff := defaultDirectRecoveryBackoff << min(failuresOverBudget, 3)
987 if backoff > maxDirectRecoveryBackoff {
988 backoff = maxDirectRecoveryBackoff
989 }
990 state.nextDiscoveryRefreshAt = now.Add(backoff)
991 s.relays[relayURL] = state
992 return true, "discovery", state.discoveryFailures
993 }
994
995 func (s *RelaySet) RecordActiveFailure(relayURL string, recoveryFailures int) (backedOff bool, backoffReason string, failureCount int) {
996 s.mu.Lock()
997 defer s.mu.Unlock()
998
999 now := time.Now().UTC()
1000 s.clearExpiredPoolBansLocked(now)
1001 state, ok := s.relays[relayURL]
1002 if !ok || state.Banned {
1003 return false, "", 0
1004 }
1005 state.activeFailures++
1006
1007 if recoveryFailures <= 0 || state.activeFailures < recoveryFailures {
1008 s.relays[relayURL] = state
1009 return false, "retry", state.activeFailures
1010 }
1011 failuresOverBudget := state.activeFailures - recoveryFailures
1012 backoff := defaultDirectRecoveryBackoff << min(failuresOverBudget, 3)
1013 if backoff > maxDirectRecoveryBackoff {
1014 backoff = maxDirectRecoveryBackoff
1015 }
1016 state.suppressActiveUntil = now.Add(backoff)
1017 s.relays[relayURL] = state
1018 return true, "active", state.activeFailures
1019 }