main
go 548 lines 14.8 KB
Raw
1 package discovery
2
3 // MOLS selection ranks relays using a GF(64)-based MOLS grid with a
4 // non-invasive adaptive partition over local load telemetry.
5 //
6 // Ordering Pipeline:
7 // 1. Filter: Apply ban, expiry, and protocol compatibility gates.
8 // 2. Extract: Keep the top fixed-depth deterministic MOLS candidates.
9 // 3. Partition: Move saturated relays behind active relays.
10 // 4. Preserve: Keep intra-tier MOLS order unchanged.
11 import (
12 "math"
13 "slices"
14 "time"
15
16 "github.com/gosuda/portal-tunnel/v2/portal/telemetry"
17 )
18
19 const (
20 molsOrder = 64
21 molsMagicConstant = molsOrder*molsOrder + 1 // n^2+1 = 4097
22
23 molsBaseM1 uint8 = 3
24 molsBaseM2 uint8 = 5
25 molsVariantM1 uint8 = 7
26 molsVariantM2 uint8 = 11
27
28 molsCongestionRTTThreshold = 500 * time.Millisecond
29 molsCVThreshold = 0.5
30 molsFallbackRTTThreshold = 2 * time.Second
31 molsMinActiveNodes = 2
32 defaultMaxActiveRelays = 3
33 molsCandidateDepth = 8
34 )
35
36 // gf64Mul performs multiplication in GF(2^6) with primitive polynomial x^6 + x + 1 (0x43).
37 func gf64Mul(a, b uint8) uint8 {
38 a &= 0x3f
39 b &= 0x3f
40 var r uint8
41 for b != 0 {
42 if b&1 != 0 {
43 r ^= a
44 }
45 if a&0x20 != 0 {
46 a = ((a << 1) ^ 0x43) & 0x3f
47 } else {
48 a = (a << 1) & 0x3f
49 }
50 b >>= 1
51 }
52 return r
53 }
54
55 // gridOrderForSize returns the smallest supported MOLS grid order that can
56 // accommodate the relay pool size.
57 func gridOrderForSize(poolSize int) int {
58 if poolSize <= molsOrder {
59 return molsOrder
60 }
61 rem := poolSize % 32
62 if rem == 0 {
63 return poolSize
64 }
65 return poolSize + (32 - rem)
66 }
67
68 func molsScore(i, j, m1, m2, order int) int {
69 if order == molsOrder {
70 l1 := gf64Mul(uint8(m1), uint8(i)) ^ uint8(j)
71 l2 := gf64Mul(uint8(m2), uint8(i)) ^ uint8(j)
72 return int(l1)*order + int(l2) + 1
73 }
74 return ((m1*i+j)%order)*order + ((m2*i + j) % order) + 1
75 }
76
77 func molsCongestionScore(i, j, m1, m2, order int) int {
78 return (order*order + 1) - molsScore(i, (order-1)-j, m1, m2, order)
79 }
80
81 func hashToGF64(s string) uint8 {
82 var h uint32 = 2166136261
83 for i := 0; i < len(s); i++ {
84 h ^= uint32(s[i])
85 h *= 16777619
86 }
87 return uint8(h & 0x3f)
88 }
89
90 func molsRTTStats(states []RelayState) (mean time.Duration, cv float64) {
91 var count int
92 var sum float64
93 for _, state := range states {
94 if state.DiscoveryRTTAt.IsZero() {
95 continue
96 }
97 count++
98 sum += float64(state.DiscoveryRTT)
99 }
100 if count == 0 {
101 return 0, 0
102 }
103 avg := sum / float64(count)
104 if count == 1 {
105 return time.Duration(avg), 0
106 }
107 var sq float64
108 for _, state := range states {
109 if state.DiscoveryRTTAt.IsZero() {
110 continue
111 }
112 d := float64(state.DiscoveryRTT) - avg
113 sq += d * d
114 }
115 stddev := math.Sqrt(sq / float64(count))
116 if avg > 0 {
117 cv = stddev / avg
118 }
119 return time.Duration(avg), cv
120 }
121
122 func isRelayFallback(state RelayState) bool {
123 return !state.DiscoveryRTTAt.IsZero() && state.DiscoveryRTT > molsFallbackRTTThreshold
124 }
125
126 type molsCandidate struct {
127 state RelayState
128 score int
129 seq int
130 }
131
132 func betterMOLSCandidate(a, b molsCandidate) bool {
133 if a.score != b.score {
134 return a.score > b.score
135 }
136 if a.state.Confirmed != b.state.Confirmed {
137 return a.state.Confirmed
138 }
139 aURL := a.state.Descriptor.APIHTTPSAddr
140 bURL := b.state.Descriptor.APIHTTPSAddr
141 if aURL != bURL {
142 return aURL < bURL
143 }
144 return a.seq < b.seq
145 }
146
147 func selectAggregate(states []RelayState) []RelayState {
148 out := make([]RelayState, 0, len(states))
149 for _, state := range states {
150 if !state.Banned {
151 out = append(out, state)
152 }
153 }
154 return out
155 }
156
157 func selectConfirmed(states []RelayState) []RelayState {
158 out := make([]RelayState, 0)
159 for _, state := range states {
160 if state.Confirmed {
161 out = append(out, state)
162 }
163 }
164 return out
165 }
166
167 func rankRelayPool(autoPool []RelayState, localAddress string) []string {
168 if len(autoPool) == 0 {
169 return nil
170 }
171
172 ingressIdx := hashToGF64(localAddress)
173 avgRTT, cv := molsRTTStats(autoPool)
174 congested := avgRTT > molsCongestionRTTThreshold
175 nonLinear := cv > molsCVThreshold
176
177 m1, m2 := molsBaseM1, molsBaseM2
178 if nonLinear {
179 m1, m2 = molsVariantM1, molsVariantM2
180 }
181
182 order := gridOrderForSize(len(autoPool))
183 scoreFor := func(state RelayState) int {
184 candidateIdx := hashToGF64(state.Descriptor.APIHTTPSAddr)
185 row := int(ingressIdx) % order
186 col := int(candidateIdx) % order
187 if congested {
188 return molsCongestionScore(row, col, int(m1), int(m2), order)
189 }
190 return molsScore(row, col, int(m1), int(m2), order)
191 }
192
193 activeStates := make([]RelayState, 0, len(autoPool))
194 fallbackStates := make([]RelayState, 0)
195 for _, state := range autoPool {
196 if isRelayFallback(state) {
197 fallbackStates = append(fallbackStates, state)
198 } else {
199 activeStates = append(activeStates, state)
200 }
201 }
202
203 if len(activeStates) < molsMinActiveNodes && len(fallbackStates) > 0 {
204 slices.SortFunc(fallbackStates, func(a, b RelayState) int {
205 if a.DiscoveryRTT < b.DiscoveryRTT {
206 return -1
207 }
208 if a.DiscoveryRTT > b.DiscoveryRTT {
209 return 1
210 }
211 return 0
212 })
213 promote := min(molsMinActiveNodes-len(activeStates), len(fallbackStates))
214 activeStates = append(activeStates, fallbackStates[:promote]...)
215 fallbackStates = fallbackStates[promote:]
216 }
217
218 rankTier := func(states []RelayState) []string {
219 if len(states) == 0 {
220 return nil
221 }
222 var candidates [molsCandidateDepth]molsCandidate
223 count := 0
224 for i, state := range states {
225 state.EvaluateSaturation()
226 candidate := molsCandidate{
227 state: state,
228 score: scoreFor(state),
229 seq: i,
230 }
231 insertAt := count
232 for insertAt > 0 && betterMOLSCandidate(candidate, candidates[insertAt-1]) {
233 if insertAt < molsCandidateDepth {
234 candidates[insertAt] = candidates[insertAt-1]
235 }
236 insertAt--
237 }
238 if insertAt >= molsCandidateDepth {
239 continue
240 }
241 candidates[insertAt] = candidate
242 if count < molsCandidateDepth {
243 count++
244 }
245 }
246
247 tierOut := make([]string, 0, count)
248 for i := 0; i < count; i++ {
249 if !candidates[i].state.IsSaturated {
250 tierOut = append(tierOut, candidates[i].state.Descriptor.APIHTTPSAddr)
251 }
252 }
253 for i := 0; i < count; i++ {
254 if candidates[i].state.IsSaturated {
255 tierOut = append(tierOut, candidates[i].state.Descriptor.APIHTTPSAddr)
256 }
257 }
258 return tierOut
259 }
260
261 activeURLs := rankTier(activeStates)
262 fallbackURLs := rankTier(fallbackStates)
263 return append(activeURLs, fallbackURLs...)
264 }
265
266 // selectPriorityWithTrace is the telemetry-instrumented sibling of
267 // SelectPriority. It returns the same ordered relay list plus a SelectionTrace.
268 func selectPriorityWithTrace(states []RelayState, cs RouteState) ([]string, telemetry.SelectionTrace) {
269 start := time.Now()
270 now := start.UTC()
271
272 trace := telemetry.SelectionTrace{
273 Timestamp: start,
274 ClientHash: hashToGF64(cs.LocalAddress),
275 Mode: "priority",
276 PoolTotal: len(states),
277 Reasons: make(map[string]string),
278 }
279
280 for _, state := range states {
281 if state.Banned {
282 url := state.Descriptor.APIHTTPSAddr
283 trace.Suppressed = append(trace.Suppressed, url)
284 trace.Reasons[url] = "banned"
285 }
286 }
287
288 selected := selectAggregate(states)
289 if len(selected) == 0 {
290 trace.SelectionTook = time.Since(start)
291 return nil, trace
292 }
293
294 explicit := make([]string, 0)
295 autoPool := make([]RelayState, 0, len(selected))
296 for _, state := range selected {
297 relayURL := state.Descriptor.APIHTTPSAddr
298 if slices.Contains(cs.ExplicitRelayURLs, relayURL) {
299 if state.hasObservedDescriptor() && state.Descriptor.ExpiresAt.After(now) {
300 if cs.RequireUDP && !state.Descriptor.SupportsUDP {
301 trace.Suppressed = append(trace.Suppressed, relayURL)
302 trace.Reasons[relayURL] = "require_udp"
303 continue
304 }
305 if cs.RequireTCP && !state.Descriptor.SupportsTCP {
306 trace.Suppressed = append(trace.Suppressed, relayURL)
307 trace.Reasons[relayURL] = "require_tcp"
308 continue
309 }
310 }
311 explicit = append(explicit, relayURL)
312 continue
313 }
314 if state.hasObservedDescriptor() {
315 if !state.Descriptor.ExpiresAt.After(now) {
316 trace.Suppressed = append(trace.Suppressed, relayURL)
317 trace.Reasons[relayURL] = "expired"
318 continue
319 }
320 if cs.RequireUDP && !state.Descriptor.SupportsUDP {
321 trace.Suppressed = append(trace.Suppressed, relayURL)
322 trace.Reasons[relayURL] = "require_udp"
323 continue
324 }
325 if cs.RequireTCP && !state.Descriptor.SupportsTCP {
326 trace.Suppressed = append(trace.Suppressed, relayURL)
327 trace.Reasons[relayURL] = "require_tcp"
328 continue
329 }
330 }
331 if !state.suppressActiveUntil.IsZero() && state.suppressActiveUntil.After(now) {
332 trace.Suppressed = append(trace.Suppressed, relayURL)
333 trace.Reasons[relayURL] = "suppressed"
334 continue
335 }
336 autoPool = append(autoPool, state)
337 }
338
339 avgRTT, cv := molsRTTStats(autoPool)
340 trace.AvgRTT = avgRTT
341 trace.CV = cv
342 congested := avgRTT > molsCongestionRTTThreshold
343 nonLinear := cv > molsCVThreshold
344 trace.Congested = congested
345 trace.NonLinear = nonLinear
346
347 m1, m2 := molsBaseM1, molsBaseM2
348 if nonLinear {
349 m1, m2 = molsVariantM1, molsVariantM2
350 }
351 trace.M1, trace.M2 = m1, m2
352
353 active, fallbacks := traceFallbackPartition(autoPool)
354 trace.PoolEligible = len(autoPool)
355 trace.PoolFallback = len(fallbacks)
356 if len(active) < molsMinActiveNodes && len(fallbacks) > 0 {
357 promote := min(molsMinActiveNodes-len(active), len(fallbacks))
358 fallbacks = fallbacks[promote:]
359 }
360 demotedURLs := relayURLSet(fallbacks)
361
362 ingressIdx := hashToGF64(cs.LocalAddress)
363 order := gridOrderForSize(len(autoPool))
364 for _, state := range autoPool {
365 candidateIdx := hashToGF64(state.Descriptor.APIHTTPSAddr)
366 row := int(ingressIdx) % order
367 col := int(candidateIdx) % order
368 score := molsScore(row, col, int(m1), int(m2), order)
369 if congested {
370 score = molsCongestionScore(row, col, int(m1), int(m2), order)
371 }
372 trace.Ranked = append(trace.Ranked, telemetry.TraceEntry{
373 URL: state.Descriptor.APIHTTPSAddr,
374 Score: score,
375 Confirmed: state.Confirmed,
376 RTT: state.DiscoveryRTT,
377 Demoted: demotedURLs[state.Descriptor.APIHTTPSAddr],
378 })
379 }
380
381 autoURLs := rankRelayPool(autoPool, cs.LocalAddress)
382 maxActiveRelays := cs.MaxActiveRelays
383 if maxActiveRelays <= 0 {
384 maxActiveRelays = defaultMaxActiveRelays
385 }
386 if len(autoURLs) > maxActiveRelays {
387 autoURLs = autoURLs[:maxActiveRelays]
388 }
389 result := append(explicit, autoURLs...)
390 trace.OutputURLs = result
391 trace.SelectionTook = time.Since(start)
392 return result, trace
393 }
394
395 func traceFallbackPartition(autoPool []RelayState) ([]RelayState, []RelayState) {
396 active := make([]RelayState, 0, len(autoPool))
397 fallbacks := make([]RelayState, 0)
398 for _, state := range autoPool {
399 if isRelayFallback(state) {
400 fallbacks = append(fallbacks, state)
401 } else {
402 active = append(active, state)
403 }
404 }
405 return active, fallbacks
406 }
407
408 func relayURLSet(states []RelayState) map[string]bool {
409 out := make(map[string]bool, len(states))
410 for _, state := range states {
411 out[state.Descriptor.APIHTTPSAddr] = true
412 }
413 return out
414 }
415
416 // SelectPriority returns the ordered list of relay URLs for a client using the
417 // MOLS selection. It delegates to selectPriorityWithTrace and discards the trace.
418 func SelectPriority(states []RelayState, routeState RouteState) []string {
419 out, _ := selectPriorityWithTrace(states, routeState)
420 return out
421 }
422
423 // selectMultiHopWithTrace is the telemetry-instrumented sibling of
424 // SelectMultiHop. It returns the same ordered relay list plus a SelectionTrace.
425 func selectMultiHopWithTrace(states []RelayState, cs RouteState) ([]string, telemetry.SelectionTrace) {
426 start := time.Now()
427 now := start.UTC()
428
429 trace := telemetry.SelectionTrace{
430 Timestamp: start,
431 ClientHash: hashToGF64(cs.LocalAddress),
432 Mode: "multihop",
433 PoolTotal: len(states),
434 Reasons: make(map[string]string),
435 }
436
437 if cs.MultiHopDepth <= 1 {
438 trace.SelectionTook = time.Since(start)
439 return nil, trace
440 }
441
442 for _, state := range states {
443 if state.Banned {
444 url := state.Descriptor.APIHTTPSAddr
445 trace.Suppressed = append(trace.Suppressed, url)
446 trace.Reasons[url] = "banned"
447 }
448 }
449
450 selected := selectAggregate(states)
451 if len(selected) == 0 {
452 trace.SelectionTook = time.Since(start)
453 return nil, trace
454 }
455
456 autoPool := make([]RelayState, 0, len(selected))
457 for _, state := range selected {
458 relayURL := state.Descriptor.APIHTTPSAddr
459 if cs.RequireUDP && state.hasObservedDescriptor() && !state.Descriptor.SupportsUDP {
460 trace.Suppressed = append(trace.Suppressed, relayURL)
461 trace.Reasons[relayURL] = "require_udp"
462 continue
463 }
464 if cs.RequireTCP && state.hasObservedDescriptor() && !state.Descriptor.SupportsTCP {
465 trace.Suppressed = append(trace.Suppressed, relayURL)
466 trace.Reasons[relayURL] = "require_tcp"
467 continue
468 }
469 if !state.hasObservedDescriptor() {
470 trace.Suppressed = append(trace.Suppressed, relayURL)
471 trace.Reasons[relayURL] = "no_descriptor"
472 continue
473 }
474 if !state.Descriptor.ExpiresAt.After(now) {
475 trace.Suppressed = append(trace.Suppressed, relayURL)
476 trace.Reasons[relayURL] = "expired"
477 continue
478 }
479 if !state.Descriptor.HasOverlayPeer() {
480 trace.Suppressed = append(trace.Suppressed, relayURL)
481 trace.Reasons[relayURL] = "no_overlay_peer"
482 continue
483 }
484 if !state.suppressActiveUntil.IsZero() && state.suppressActiveUntil.After(now) {
485 trace.Suppressed = append(trace.Suppressed, relayURL)
486 trace.Reasons[relayURL] = "suppressed"
487 continue
488 }
489 autoPool = append(autoPool, state)
490 }
491
492 avgRTT, cv := molsRTTStats(autoPool)
493 trace.AvgRTT = avgRTT
494 trace.CV = cv
495 congested := avgRTT > molsCongestionRTTThreshold
496 nonLinear := cv > molsCVThreshold
497 trace.Congested = congested
498 trace.NonLinear = nonLinear
499
500 m1, m2 := molsBaseM1, molsBaseM2
501 if nonLinear {
502 m1, m2 = molsVariantM1, molsVariantM2
503 }
504 trace.M1, trace.M2 = m1, m2
505
506 active, fallbacks := traceFallbackPartition(autoPool)
507 trace.PoolEligible = len(autoPool)
508 trace.PoolFallback = len(fallbacks)
509 if len(active) < molsMinActiveNodes && len(fallbacks) > 0 {
510 promote := min(molsMinActiveNodes-len(active), len(fallbacks))
511 fallbacks = fallbacks[promote:]
512 }
513 demotedURLs := relayURLSet(fallbacks)
514
515 ingressIdx := hashToGF64(cs.LocalAddress)
516 order := gridOrderForSize(len(autoPool))
517 for _, state := range autoPool {
518 candidateIdx := hashToGF64(state.Descriptor.APIHTTPSAddr)
519 row := int(ingressIdx) % order
520 col := int(candidateIdx) % order
521 score := molsScore(row, col, int(m1), int(m2), order)
522 if congested {
523 score = molsCongestionScore(row, col, int(m1), int(m2), order)
524 }
525 trace.Ranked = append(trace.Ranked, telemetry.TraceEntry{
526 URL: state.Descriptor.APIHTTPSAddr,
527 Score: score,
528 Confirmed: state.Confirmed,
529 RTT: state.DiscoveryRTT,
530 Demoted: demotedURLs[state.Descriptor.APIHTTPSAddr],
531 })
532 }
533
534 multiHop := rankRelayPool(autoPool, cs.LocalAddress)
535 if len(multiHop) > cs.MultiHopDepth {
536 multiHop = multiHop[:cs.MultiHopDepth]
537 }
538 trace.OutputURLs = multiHop
539 trace.SelectionTook = time.Since(start)
540 return multiHop, trace
541 }
542
543 // SelectMultiHop returns the ordered list of relay URLs for multi-hop routing.
544 // It delegates to selectMultiHopWithTrace and discards the trace.
545 func SelectMultiHop(states []RelayState, routeState RouteState) []string {
546 out, _ := selectMultiHopWithTrace(states, routeState)
547 return out
548 }