main
go 495 lines 14.4 KB
Raw
1 package discovery
2
3 import (
4 "testing"
5 "time"
6
7 "github.com/gosuda/portal-tunnel/v2/types"
8 )
9
10 func relayStates(set *RelaySet) []RelayState {
11 set.mu.RLock()
12 defer set.mu.RUnlock()
13 states := make([]RelayState, 0, len(set.relays))
14 for _, state := range set.relays {
15 if !state.Banned {
16 states = append(states, state)
17 }
18 }
19 return states
20 }
21
22 func mustRelayDescriptor(t *testing.T, relayURL string) types.RelayDescriptor {
23 t.Helper()
24 now := time.Now().UTC().Truncate(time.Microsecond)
25 return mustSignedDescriptor(t, mustSigningIdentity(t), relayURL, now)
26 }
27
28 func confirmedRelayState(t *testing.T, relayURL string) RelayState {
29 t.Helper()
30 return RelayState{
31 Descriptor: mustRelayDescriptor(t, relayURL),
32 Confirmed: true,
33 LastSeenAt: time.Now().UTC(),
34 }
35 }
36
37 func bootstrapRelayState(relayURL string) RelayState {
38 state := newRelayState(relayURL)
39 state.Bootstrap = true
40 return state
41 }
42
43 func TestApplyRelayDiscoveryResponsePreservesBootstrapFlag(t *testing.T) {
44 set := NewRelaySet([]string{"https://relay-a.example"})
45
46 desc := mustRelayDescriptor(t, "https://relay-a.example")
47 if _, err := set.ApplyRelayDiscoveryResponse(desc.APIHTTPSAddr, types.DiscoveryResponse{
48 ProtocolVersion: types.DiscoveryVersion,
49 Relays: []types.RelayDescriptor{desc},
50 }, time.Now().UTC()); err != nil {
51 t.Fatalf("ApplyRelayDiscoveryResponse() error = %v", err)
52 }
53
54 states := relayStates(set)
55 if len(states) != 1 {
56 t.Fatalf("len(relayStates()) = %d, want 1", len(states))
57 }
58 if !states[0].Bootstrap {
59 t.Fatal("bootstrap relay lost bootstrap flag after discovery update")
60 }
61 }
62
63 func TestDescriptorsDropsExpiredSignedRelayDescriptor(t *testing.T) {
64 set := NewRelaySet(nil)
65
66 now := time.Now().UTC()
67 relayURL := "https://relay-stale.example"
68 state := confirmedRelayState(t, relayURL)
69 state.Descriptor.ExpiresAt = now.Add(-time.Minute)
70 state.LastSeenAt = now.Add(-6 * time.Hour)
71 state.Descriptor.SupportsUDP = true
72 state.Descriptor.SupportsTCP = true
73
74 set.mu.Lock()
75 set.relays[relayURL] = state
76 set.mu.Unlock()
77
78 descriptors := set.Descriptors(types.RelayDescriptor{})
79 if len(descriptors) != 0 {
80 t.Fatalf("len(Descriptors(empty)) = %d, want 0", len(descriptors))
81 }
82 }
83
84 func TestApplyRelayDiscoveryResponseCollectsRelaysDespiteProtocolMismatch(t *testing.T) {
85 set := NewRelaySet(nil)
86
87 desc := mustRelayDescriptor(t, "https://relay-mismatch.example")
88 changed, err := set.ApplyRelayDiscoveryResponse("", types.DiscoveryResponse{
89 ProtocolVersion: "5",
90 Relays: []types.RelayDescriptor{desc},
91 }, time.Now().UTC())
92 if err != nil {
93 t.Fatalf("ApplyRelayDiscoveryResponse() error = %v", err)
94 }
95 if !changed {
96 t.Fatal("expected protocol-mismatched discovery response to change relay set")
97 }
98
99 states := relayStates(set)
100 if len(states) != 1 {
101 t.Fatalf("len(relayStates()) = %d, want 1", len(states))
102 }
103 if got := states[0].Descriptor.APIHTTPSAddr; got != desc.APIHTTPSAddr {
104 t.Fatalf("states[0] = %q, want %q", got, desc.APIHTTPSAddr)
105 }
106 if states[0].Confirmed {
107 t.Fatal("hinted relay should not become locally confirmed from aggregation")
108 }
109 }
110
111 func TestApplyRelayDiscoveryResponseCollectsHintsWhenTargetDescriptorIsMissing(t *testing.T) {
112 set := NewRelaySet(nil)
113
114 hinted := mustRelayDescriptor(t, "https://relay-hinted.example")
115 changed, err := set.ApplyRelayDiscoveryResponse("https://relay-source.example", types.DiscoveryResponse{
116 ProtocolVersion: "5",
117 Relays: []types.RelayDescriptor{hinted},
118 }, time.Now().UTC())
119 if err == nil {
120 t.Fatal("expected missing target descriptor error")
121 }
122 if !changed {
123 t.Fatal("expected hinted relay to still be collected")
124 }
125
126 states := relayStates(set)
127 if len(states) != 1 {
128 t.Fatalf("len(relayStates()) = %d, want 1", len(states))
129 }
130 if got := states[0].Descriptor.APIHTTPSAddr; got != hinted.APIHTTPSAddr {
131 t.Fatalf("states[0] = %q, want %q", got, hinted.APIHTTPSAddr)
132 }
133 if states[0].Confirmed {
134 t.Fatal("hinted relay should not become locally confirmed when target descriptor is missing")
135 }
136 }
137
138 func TestApplyRelayDiscoveryResponseClearsDiscoveryRetryOnAuthoritativeSuccess(t *testing.T) {
139 set := NewRelaySet(nil)
140
141 relayURL := "https://relay-source.example"
142 desc := mustRelayDescriptor(t, relayURL)
143 set.mu.Lock()
144 state := RelayState{
145 Descriptor: desc,
146 LastSeenAt: time.Now().UTC(),
147 discoveryFailures: defaultRecoveryFailures,
148 nextDiscoveryRefreshAt: time.Now().UTC().Add(time.Minute),
149 activeFailures: 1,
150 suppressActiveUntil: time.Now().UTC().Add(time.Minute),
151 }
152 set.relays[relayURL] = state
153 set.mu.Unlock()
154
155 if _, err := set.ApplyRelayDiscoveryResponse(relayURL, types.DiscoveryResponse{
156 ProtocolVersion: types.DiscoveryVersion,
157 Relays: []types.RelayDescriptor{desc},
158 }, time.Now().UTC()); err != nil {
159 t.Fatalf("ApplyRelayDiscoveryResponse() error = %v", err)
160 }
161
162 set.mu.RLock()
163 refreshed := set.relays[relayURL]
164 set.mu.RUnlock()
165 if refreshed.discoveryFailures != 0 {
166 t.Fatalf("discoveryFailures = %d, want 0", refreshed.discoveryFailures)
167 }
168 if !refreshed.nextDiscoveryRefreshAt.IsZero() {
169 t.Fatalf("nextDiscoveryRefreshAt = %v, want zero time", refreshed.nextDiscoveryRefreshAt)
170 }
171 if refreshed.activeFailures != 1 {
172 t.Fatalf("activeFailures = %d, want 1", refreshed.activeFailures)
173 }
174 if refreshed.suppressActiveUntil.IsZero() {
175 t.Fatal("suppressActiveUntil was cleared by discovery success")
176 }
177 }
178
179 func TestApplyRelayDiscoveryResponsePreservesDiscoveryRetryOnHint(t *testing.T) {
180 set := NewRelaySet(nil)
181
182 relayURL := "https://relay-hinted.example"
183 desc := mustRelayDescriptor(t, relayURL)
184 nextDiscoveryRefreshAt := time.Now().UTC().Add(time.Minute)
185 set.mu.Lock()
186 state := RelayState{
187 Descriptor: desc,
188 LastSeenAt: time.Now().UTC(),
189 nextDiscoveryRefreshAt: nextDiscoveryRefreshAt,
190 }
191 set.relays[relayURL] = state
192 set.mu.Unlock()
193
194 if _, err := set.ApplyRelayDiscoveryResponse("", types.DiscoveryResponse{
195 ProtocolVersion: types.DiscoveryVersion,
196 Relays: []types.RelayDescriptor{desc},
197 }, time.Now().UTC()); err != nil {
198 t.Fatalf("ApplyRelayDiscoveryResponse() error = %v", err)
199 }
200
201 set.mu.RLock()
202 refreshed := set.relays[relayURL]
203 set.mu.RUnlock()
204 if !refreshed.nextDiscoveryRefreshAt.Equal(nextDiscoveryRefreshAt) {
205 t.Fatalf("nextDiscoveryRefreshAt = %v, want %v", refreshed.nextDiscoveryRefreshAt, nextDiscoveryRefreshAt)
206 }
207 }
208
209 func TestConfirmRelayURLMarksRelayConfirmedWithoutChangingAggregateDescriptor(t *testing.T) {
210 set := NewRelaySet(nil)
211
212 relayURL := "https://relay-confirmed.example"
213 state := RelayState{
214 Descriptor: mustRelayDescriptor(t, relayURL),
215 LastSeenAt: time.Now().UTC(),
216 }
217
218 set.mu.Lock()
219 set.relays[relayURL] = state
220 set.mu.Unlock()
221
222 set.ConfirmRelayURL(relayURL)
223
224 set.mu.RLock()
225 confirmed := set.relays[relayURL]
226 set.mu.RUnlock()
227 if !confirmed.Confirmed {
228 t.Fatal("relay should become locally confirmed after listener success")
229 }
230 if confirmed.Descriptor.APIHTTPSAddr != relayURL {
231 t.Fatalf("descriptor api_https_addr = %q, want %q", confirmed.Descriptor.APIHTTPSAddr, relayURL)
232 }
233 }
234
235 func TestConfirmRelayURLResetsActiveFailures(t *testing.T) {
236 relayURL := "https://error.io"
237 set := NewRelaySet(nil)
238 state := RelayState{
239 Descriptor: types.RelayDescriptor{
240 APIHTTPSAddr: relayURL,
241 },
242 activeFailures: 5,
243 suppressActiveUntil: time.Now().UTC().Add(time.Minute),
244 }
245 set.mu.Lock()
246 set.relays[relayURL] = state
247 set.mu.Unlock()
248
249 set.ConfirmRelayURL(relayURL)
250
251 set.mu.RLock()
252 state = set.relays[relayURL]
253 set.mu.RUnlock()
254 if !state.Confirmed {
255 t.Fatal("relay should be confirmed")
256 }
257 if state.activeFailures != 0 {
258 t.Fatalf("activeFailures = %d, want 0", state.activeFailures)
259 }
260 if !state.suppressActiveUntil.IsZero() {
261 t.Fatalf("suppressActiveUntil = %v, want zero", state.suppressActiveUntil)
262 }
263 }
264
265 func TestUnconfirmRelayURLClearsLocalConfirmationOnly(t *testing.T) {
266 set := NewRelaySet(nil)
267
268 relayURL := "https://relay-confirmed.example"
269 state := confirmedRelayState(t, relayURL)
270
271 set.mu.Lock()
272 set.relays[relayURL] = state
273 set.mu.Unlock()
274
275 set.UnconfirmRelayURL(relayURL)
276
277 set.mu.RLock()
278 unconfirmed := set.relays[relayURL]
279 set.mu.RUnlock()
280 if unconfirmed.Confirmed {
281 t.Fatal("relay should lose local confirmation after listener failure")
282 }
283 }
284
285 func TestRecordDiscoveryFailureBackoff(t *testing.T) {
286 relayURL := "https://discovery-error.example"
287 set := NewRelaySet(nil)
288 set.mu.Lock()
289 set.relays[relayURL] = confirmedRelayState(t, relayURL)
290 set.mu.Unlock()
291 budget := 3
292
293 start := time.Now()
294 for i := 0; i < budget; i++ {
295 backedOff, _, _ := set.RecordDiscoveryFailure(relayURL, budget)
296 if i < budget-1 && backedOff {
297 t.Fatal("discovery failure backed off before budget")
298 }
299 }
300
301 set.mu.RLock()
302 state := set.relays[relayURL]
303 set.mu.RUnlock()
304 if !state.nextDiscoveryRefreshAt.After(start) {
305 t.Fatal("discovery retry timer was not scheduled")
306 }
307 if !state.suppressActiveUntil.IsZero() {
308 t.Fatalf("suppressActiveUntil = %v, want zero", state.suppressActiveUntil)
309 }
310 }
311
312 func TestRecordActiveFailureBackoff(t *testing.T) {
313 relayURL := "https://active-error.example"
314 set := NewRelaySet(nil)
315 set.mu.Lock()
316 set.relays[relayURL] = confirmedRelayState(t, relayURL)
317 set.mu.Unlock()
318 start := time.Now()
319
320 backedOff, _, _ := set.RecordActiveFailure(relayURL, 1)
321 if !backedOff {
322 t.Fatal("active failure should back off at budget")
323 }
324 set.mu.RLock()
325 state := set.relays[relayURL]
326 set.mu.RUnlock()
327 if !state.suppressActiveUntil.After(start) {
328 t.Fatal("active suppression timer was not scheduled")
329 }
330 if !state.nextDiscoveryRefreshAt.IsZero() {
331 t.Fatalf("nextDiscoveryRefreshAt = %v, want zero", state.nextDiscoveryRefreshAt)
332 }
333 }
334
335 func TestRecordDiscoveryFailurePoolBansLongUnhealthyRelay(t *testing.T) {
336 set := NewRelaySet(nil)
337
338 relayURL := "https://relay-unhealthy.example"
339 state := confirmedRelayState(t, relayURL)
340 state.unhealthySince = time.Now().UTC().Add(-AnnounceMaxValidity - time.Minute)
341
342 set.mu.Lock()
343 set.relays[relayURL] = state
344 set.mu.Unlock()
345
346 backedOff, reason, failures := set.RecordDiscoveryFailure(relayURL, 1)
347 if !backedOff || reason != "unhealthy" {
348 t.Fatalf("RecordDiscoveryFailure() = (%v, %q), want unhealthy pool ban", backedOff, reason)
349 }
350 if failures != 1 {
351 t.Fatalf("failure count = %d, want 1", failures)
352 }
353
354 set.mu.RLock()
355 quarantined := set.relays[relayURL]
356 set.mu.RUnlock()
357 if !quarantined.Banned {
358 t.Fatal("unhealthy relay was not pool-banned")
359 }
360 if quarantined.hasObservedDescriptor() {
361 t.Fatal("unhealthy relay descriptor should be removed from active relay pool")
362 }
363 if !quarantined.suppressActiveUntil.After(time.Now().UTC().Add(relayPoolBanTTL - time.Minute)) {
364 t.Fatalf("pool ban until = %v, want about %v from now", quarantined.suppressActiveUntil, relayPoolBanTTL)
365 }
366 }
367
368 func TestRecordActiveFailureDoesNotPoolBanLongUnhealthyRelay(t *testing.T) {
369 set := NewRelaySet(nil)
370
371 relayURL := "https://relay-active-unhealthy.example"
372 state := confirmedRelayState(t, relayURL)
373 state.unhealthySince = time.Now().UTC().Add(-AnnounceMaxValidity - time.Minute)
374
375 set.mu.Lock()
376 set.relays[relayURL] = state
377 set.mu.Unlock()
378
379 backedOff, reason, failures := set.RecordActiveFailure(relayURL, 1)
380 if !backedOff || reason != "active" {
381 t.Fatalf("RecordActiveFailure() = (%v, %q), want active backoff", backedOff, reason)
382 }
383 if failures != 1 {
384 t.Fatalf("failure count = %d, want 1", failures)
385 }
386
387 set.mu.RLock()
388 activeBackoff := set.relays[relayURL]
389 set.mu.RUnlock()
390 if activeBackoff.Banned {
391 t.Fatal("active failure should not pool-ban relay")
392 }
393 if !activeBackoff.hasObservedDescriptor() {
394 t.Fatal("active failure should not remove relay descriptor")
395 }
396 if activeBackoff.suppressActiveUntil.IsZero() {
397 t.Fatal("active failure should schedule active suppression")
398 }
399 }
400
401 func TestPoolBanRejectsDiscoveryUntilExpiry(t *testing.T) {
402 set := NewRelaySet(nil)
403
404 relayURL := "https://relay-quarantined.example"
405 desc := mustRelayDescriptor(t, relayURL)
406
407 set.mu.Lock()
408 state := newRelayState(relayURL)
409 state.Banned = true
410 state.suppressActiveUntil = time.Now().UTC().Add(time.Hour)
411 set.relays[relayURL] = state
412 set.mu.Unlock()
413
414 changed, err := set.ApplyRelayDiscoveryResponse("", types.DiscoveryResponse{
415 ProtocolVersion: types.DiscoveryVersion,
416 Relays: []types.RelayDescriptor{desc},
417 }, time.Now().UTC())
418 if err != nil {
419 t.Fatalf("ApplyRelayDiscoveryResponse() error = %v", err)
420 }
421 if changed {
422 t.Fatal("pool-banned relay should not change relay set")
423 }
424 if got := relayStates(set); len(got) != 0 {
425 t.Fatalf("len(relayStates()) = %d, want 0", len(got))
426 }
427
428 set.mu.Lock()
429 state = set.relays[relayURL]
430 state.suppressActiveUntil = time.Now().UTC().Add(-time.Second)
431 set.relays[relayURL] = state
432 set.mu.Unlock()
433
434 changed, err = set.ApplyRelayDiscoveryResponse("", types.DiscoveryResponse{
435 ProtocolVersion: types.DiscoveryVersion,
436 Relays: []types.RelayDescriptor{desc},
437 }, time.Now().UTC())
438 if err != nil {
439 t.Fatalf("ApplyRelayDiscoveryResponse() after expiry error = %v", err)
440 }
441 if !changed {
442 t.Fatal("expired pool ban should allow relay set update")
443 }
444 if got := relayStates(set); len(got) != 1 || got[0].Descriptor.APIHTTPSAddr != relayURL {
445 t.Fatalf("relayStates() = %v, want relay %q", got, relayURL)
446 }
447 }
448
449 func TestPlanRoutesExplicitPathReturnsSingleRouteToExit(t *testing.T) {
450 const (
451 entry = "https://entry.example"
452 mid = "https://middle.example"
453 exit = "https://exit.example"
454 )
455
456 routes, err := NewRelaySet(nil).PlanRoutes([]string{entry, mid, exit}, RouteState{})
457 if err != nil {
458 t.Fatalf("PlanRoutes() error = %v", err)
459 }
460 if len(routes) != 1 {
461 t.Fatalf("len(routes) = %d, want 1", len(routes))
462 }
463 route := routes[0]
464 if !route.Explicit() {
465 t.Fatal("route.Explicit() = false, want true")
466 }
467 if got := route.ListenerRelayURL(); got != exit {
468 t.Fatalf("ListenerRelayURL() = %q, want %q", got, exit)
469 }
470 path := route.MultiHop()
471 if len(path) != 3 || path[0] != entry || path[1] != mid || path[2] != exit {
472 t.Fatalf("MultiHop() = %v, want [%q %q %q]", path, entry, mid, exit)
473 }
474 }
475
476 func TestPlanRoutesIncludesExplicitRelayMissingFromSet(t *testing.T) {
477 const relayURL = "https://relay-explicit.example"
478
479 routes, err := NewRelaySet(nil).PlanRoutes(nil, RouteState{
480 ExplicitRelayURLs: []string{relayURL},
481 })
482 if err != nil {
483 t.Fatalf("PlanRoutes() error = %v", err)
484 }
485 if len(routes) != 1 {
486 t.Fatalf("len(routes) = %d, want 1", len(routes))
487 }
488 route := routes[0]
489 if !route.Explicit() {
490 t.Fatal("route.Explicit() = false, want true")
491 }
492 if got := route.ListenerRelayURL(); got != relayURL {
493 t.Fatalf("ListenerRelayURL() = %q, want %q", got, relayURL)
494 }
495 }