mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat: validate matchmaking latency evidence
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ProbeFreshness = 30 * time.Second
|
||||
ProbeFutureSkew = 5 * time.Second
|
||||
MaxOpaqueLocationBytes = 512
|
||||
DiscrepancyWindow = 24 * time.Hour
|
||||
DiscrepancyLimit = 3
|
||||
CleanSamplesToRelease = 5
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidProbe = errors.New("invalid latency probe evidence")
|
||||
ErrProbeQuarantined = errors.New("latency samples are quarantined")
|
||||
)
|
||||
|
||||
// ProbeEvidence deliberately treats Steam's location as opaque. The backend
|
||||
// validates freshness/nonce and computes RTT from its own receive timestamps;
|
||||
// no client-provided RTT is used for placement.
|
||||
type ProbeEvidence struct {
|
||||
OpaqueLocation []byte
|
||||
Nonce []byte
|
||||
IssuedAt time.Time
|
||||
Region string
|
||||
ServerRTT time.Duration
|
||||
}
|
||||
|
||||
func ValidateProbe(evidence ProbeEvidence, expectedNonce []byte, now time.Time) error {
|
||||
if len(evidence.OpaqueLocation) == 0 || len(evidence.OpaqueLocation) > MaxOpaqueLocationBytes || len(expectedNonce) == 0 {
|
||||
return ErrInvalidProbe
|
||||
}
|
||||
if len(evidence.Nonce) != len(expectedNonce) || subtle.ConstantTimeCompare(evidence.Nonce, expectedNonce) != 1 {
|
||||
return fmt.Errorf("%w: nonce mismatch", ErrInvalidProbe)
|
||||
}
|
||||
if evidence.IssuedAt.After(now.Add(ProbeFutureSkew)) || now.Sub(evidence.IssuedAt) > ProbeFreshness {
|
||||
return fmt.Errorf("%w: stale or future timestamp", ErrInvalidProbe)
|
||||
}
|
||||
if evidence.Region != "EU" && evidence.Region != "NA" {
|
||||
return fmt.Errorf("%w: unsupported region", ErrInvalidProbe)
|
||||
}
|
||||
if evidence.ServerRTT < 0 {
|
||||
return fmt.Errorf("%w: negative RTT", ErrInvalidProbe)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type DiscrepancyTracker struct {
|
||||
BadSamples []time.Time
|
||||
CleanSamples int
|
||||
Quarantined bool
|
||||
}
|
||||
|
||||
// RecordComparison compares backend-computed predicted and observed RTT. A
|
||||
// discrepancy is over 25 ms or 30% (whichever is larger). Three bad samples
|
||||
// in 24 hours quarantine placement evidence; five clean samples release it.
|
||||
func (tracker *DiscrepancyTracker) RecordComparison(predicted, observed time.Duration, now time.Time) {
|
||||
tracker.prune(now)
|
||||
maxAllowed := 25 * time.Millisecond
|
||||
if predicted > 0 {
|
||||
percent := time.Duration(float64(predicted) * 0.30)
|
||||
if percent > maxAllowed { maxAllowed = percent }
|
||||
}
|
||||
delta := predicted - observed
|
||||
if delta < 0 { delta = -delta }
|
||||
if delta > maxAllowed {
|
||||
tracker.BadSamples = append(tracker.BadSamples, now)
|
||||
tracker.CleanSamples = 0
|
||||
if len(tracker.BadSamples) >= DiscrepancyLimit { tracker.Quarantined = true }
|
||||
return
|
||||
}
|
||||
if tracker.Quarantined {
|
||||
tracker.CleanSamples++
|
||||
if tracker.CleanSamples >= CleanSamplesToRelease {
|
||||
tracker.Quarantined = false
|
||||
tracker.BadSamples = nil
|
||||
tracker.CleanSamples = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tracker *DiscrepancyTracker) prune(now time.Time) {
|
||||
cutoff := now.Add(-DiscrepancyWindow)
|
||||
kept := tracker.BadSamples[:0]
|
||||
for _, sample := range tracker.BadSamples {
|
||||
if !sample.Before(cutoff) { kept = append(kept, sample) }
|
||||
}
|
||||
tracker.BadSamples = kept
|
||||
}
|
||||
|
||||
func (tracker DiscrepancyTracker) PlacementAllowed() error {
|
||||
if tracker.Quarantined { return ErrProbeQuarantined }
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateProbeRequiresOpaqueFreshNonceAndServerRTT(t *testing.T) {
|
||||
now := time.Unix(100000, 0)
|
||||
valid := ProbeEvidence{OpaqueLocation: []byte("opaque"), Nonce: []byte("nonce"), IssuedAt: now, Region: "EU", ServerRTT: 40 * time.Millisecond}
|
||||
if err := ValidateProbe(valid, []byte("nonce"), now); err != nil { t.Fatal(err) }
|
||||
for name, invalid := range map[string]ProbeEvidence{
|
||||
"empty location": {Nonce: []byte("nonce"), IssuedAt: now, Region: "EU"},
|
||||
"wrong nonce": {OpaqueLocation: []byte("opaque"), Nonce: []byte("other"), IssuedAt: now, Region: "EU"},
|
||||
"stale": {OpaqueLocation: []byte("opaque"), Nonce: []byte("nonce"), IssuedAt: now.Add(-ProbeFreshness - time.Nanosecond), Region: "EU"},
|
||||
"client chosen negative RTT": {OpaqueLocation: []byte("opaque"), Nonce: []byte("nonce"), IssuedAt: now, Region: "EU", ServerRTT: -time.Millisecond},
|
||||
} {
|
||||
if err := ValidateProbe(invalid, []byte("nonce"), now); !errors.Is(err, ErrInvalidProbe) { t.Fatalf("%s error = %v", name, err) }
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscrepancyQuarantineAndFiveCleanRelease(t *testing.T) {
|
||||
now := time.Unix(100000, 0)
|
||||
var tracker DiscrepancyTracker
|
||||
for i := 0; i < DiscrepancyLimit; i++ { tracker.RecordComparison(40*time.Millisecond, 100*time.Millisecond, now.Add(time.Duration(i)*time.Minute)) }
|
||||
if !tracker.Quarantined { t.Fatal("three discrepancies did not quarantine samples") }
|
||||
if err := tracker.PlacementAllowed(); !errors.Is(err, ErrProbeQuarantined) { t.Fatal("quarantine not enforced") }
|
||||
for i := 0; i < CleanSamplesToRelease; i++ { tracker.RecordComparison(40*time.Millisecond, 45*time.Millisecond, now.Add(time.Hour+time.Duration(i)*time.Minute)) }
|
||||
if tracker.Quarantined { t.Fatal("five clean samples did not release quarantine") }
|
||||
}
|
||||
|
||||
func TestDiscrepancyThresholdUsesLargerOfAbsoluteAndRelativeLimit(t *testing.T) {
|
||||
now := time.Unix(100000, 0)
|
||||
var tracker DiscrepancyTracker
|
||||
tracker.RecordComparison(200*time.Millisecond, 250*time.Millisecond, now)
|
||||
if tracker.Quarantined { t.Fatal("50ms discrepancy should be allowed when 30%% limit is 60ms") }
|
||||
tracker.RecordComparison(200*time.Millisecond, 270*time.Millisecond, now.Add(time.Minute))
|
||||
if len(tracker.BadSamples) != 1 { t.Fatal("70ms discrepancy should be recorded") }
|
||||
}
|
||||
Reference in New Issue
Block a user