diff --git a/hscontrol/derp/derp.go b/hscontrol/derp/derp.go index c6eb52bb..b3e2475d 100644 --- a/hscontrol/derp/derp.go +++ b/hscontrol/derp/derp.go @@ -1,13 +1,19 @@ package derp import ( + "cmp" "context" "encoding/json" + "hash/crc64" "io" "maps" + "math/rand" "net/http" "net/url" "os" + "reflect" + "sync" + "time" "github.com/juanfont/headscale/hscontrol/types" "github.com/spf13/viper" @@ -104,6 +110,51 @@ func GetDERPMap(cfg types.DERPConfig) (*tailcfg.DERPMap, error) { } derpMap := mergeDERPMaps(derpMaps) + shuffleDERPMap(derpMap) return derpMap, nil } + +func shuffleDERPMap(dm *tailcfg.DERPMap) { + if dm == nil || len(dm.Regions) == 0 { + return + } + + for id, region := range dm.Regions { + if len(region.Nodes) == 0 { + continue + } + + dm.Regions[id] = shuffleRegionNoClone(region) + } +} + +var crc64Table = crc64.MakeTable(crc64.ISO) + +var ( + derpRandomOnce sync.Once + derpRandomInst *rand.Rand + derpRandomMu sync.RWMutex +) + +func derpRandom() *rand.Rand { + derpRandomOnce.Do(func() { + seed := cmp.Or(viper.GetString("dns.base_domain"), time.Now().String()) + rnd := rand.New(rand.NewSource(0)) + rnd.Seed(int64(crc64.Checksum([]byte(seed), crc64Table))) + derpRandomInst = rnd + }) + return derpRandomInst +} + +func resetDerpRandomForTesting() { + derpRandomMu.Lock() + defer derpRandomMu.Unlock() + derpRandomOnce = sync.Once{} + derpRandomInst = nil +} + +func shuffleRegionNoClone(r *tailcfg.DERPRegion) *tailcfg.DERPRegion { + derpRandom().Shuffle(len(r.Nodes), reflect.Swapper(r.Nodes)) + return r +} diff --git a/hscontrol/derp/derp_test.go b/hscontrol/derp/derp_test.go new file mode 100644 index 00000000..2e8ace91 --- /dev/null +++ b/hscontrol/derp/derp_test.go @@ -0,0 +1,284 @@ +package derp + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/spf13/viper" + "tailscale.com/tailcfg" +) + +func TestShuffleDERPMapDeterministic(t *testing.T) { + tests := []struct { + name string + baseDomain string + derpMap *tailcfg.DERPMap + expected *tailcfg.DERPMap + }{ + { + name: "single region with 4 nodes", + baseDomain: "test1.example.com", + derpMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + RegionID: 1, + RegionCode: "nyc", + RegionName: "New York City", + Nodes: []*tailcfg.DERPNode{ + {Name: "1f", RegionID: 1, HostName: "derp1f.tailscale.com"}, + {Name: "1g", RegionID: 1, HostName: "derp1g.tailscale.com"}, + {Name: "1h", RegionID: 1, HostName: "derp1h.tailscale.com"}, + {Name: "1i", RegionID: 1, HostName: "derp1i.tailscale.com"}, + }, + }, + }, + }, + expected: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + RegionID: 1, + RegionCode: "nyc", + RegionName: "New York City", + Nodes: []*tailcfg.DERPNode{ + {Name: "1g", RegionID: 1, HostName: "derp1g.tailscale.com"}, + {Name: "1f", RegionID: 1, HostName: "derp1f.tailscale.com"}, + {Name: "1i", RegionID: 1, HostName: "derp1i.tailscale.com"}, + {Name: "1h", RegionID: 1, HostName: "derp1h.tailscale.com"}, + }, + }, + }, + }, + }, + { + name: "multiple regions with nodes", + baseDomain: "test2.example.com", + derpMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 10: { + RegionID: 10, + RegionCode: "sea", + RegionName: "Seattle", + Nodes: []*tailcfg.DERPNode{ + {Name: "10b", RegionID: 10, HostName: "derp10b.tailscale.com"}, + {Name: "10c", RegionID: 10, HostName: "derp10c.tailscale.com"}, + {Name: "10d", RegionID: 10, HostName: "derp10d.tailscale.com"}, + }, + }, + 2: { + RegionID: 2, + RegionCode: "sfo", + RegionName: "San Francisco", + Nodes: []*tailcfg.DERPNode{ + {Name: "2d", RegionID: 2, HostName: "derp2d.tailscale.com"}, + {Name: "2e", RegionID: 2, HostName: "derp2e.tailscale.com"}, + {Name: "2f", RegionID: 2, HostName: "derp2f.tailscale.com"}, + }, + }, + }, + }, + expected: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 10: { + RegionID: 10, + RegionCode: "sea", + RegionName: "Seattle", + Nodes: []*tailcfg.DERPNode{ + {Name: "10b", RegionID: 10, HostName: "derp10b.tailscale.com"}, + {Name: "10c", RegionID: 10, HostName: "derp10c.tailscale.com"}, + {Name: "10d", RegionID: 10, HostName: "derp10d.tailscale.com"}, + }, + }, + 2: { + RegionID: 2, + RegionCode: "sfo", + RegionName: "San Francisco", + Nodes: []*tailcfg.DERPNode{ + {Name: "2f", RegionID: 2, HostName: "derp2f.tailscale.com"}, + {Name: "2e", RegionID: 2, HostName: "derp2e.tailscale.com"}, + {Name: "2d", RegionID: 2, HostName: "derp2d.tailscale.com"}, + }, + }, + }, + }, + }, + { + name: "large region with many nodes", + baseDomain: "test3.example.com", + derpMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 4: { + RegionID: 4, + RegionCode: "fra", + RegionName: "Frankfurt", + Nodes: []*tailcfg.DERPNode{ + {Name: "4f", RegionID: 4, HostName: "derp4f.tailscale.com"}, + {Name: "4g", RegionID: 4, HostName: "derp4g.tailscale.com"}, + {Name: "4h", RegionID: 4, HostName: "derp4h.tailscale.com"}, + {Name: "4i", RegionID: 4, HostName: "derp4i.tailscale.com"}, + }, + }, + }, + }, + expected: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 4: { + RegionID: 4, + RegionCode: "fra", + RegionName: "Frankfurt", + Nodes: []*tailcfg.DERPNode{ + {Name: "4f", RegionID: 4, HostName: "derp4f.tailscale.com"}, + {Name: "4h", RegionID: 4, HostName: "derp4h.tailscale.com"}, + {Name: "4g", RegionID: 4, HostName: "derp4g.tailscale.com"}, + {Name: "4i", RegionID: 4, HostName: "derp4i.tailscale.com"}, + }, + }, + }, + }, + }, + { + name: "same region different base domain", + baseDomain: "different.example.com", + derpMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 4: { + RegionID: 4, + RegionCode: "fra", + RegionName: "Frankfurt", + Nodes: []*tailcfg.DERPNode{ + {Name: "4f", RegionID: 4, HostName: "derp4f.tailscale.com"}, + {Name: "4g", RegionID: 4, HostName: "derp4g.tailscale.com"}, + {Name: "4h", RegionID: 4, HostName: "derp4h.tailscale.com"}, + {Name: "4i", RegionID: 4, HostName: "derp4i.tailscale.com"}, + }, + }, + }, + }, + expected: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 4: { + RegionID: 4, + RegionCode: "fra", + RegionName: "Frankfurt", + Nodes: []*tailcfg.DERPNode{ + {Name: "4g", RegionID: 4, HostName: "derp4g.tailscale.com"}, + {Name: "4i", RegionID: 4, HostName: "derp4i.tailscale.com"}, + {Name: "4f", RegionID: 4, HostName: "derp4f.tailscale.com"}, + {Name: "4h", RegionID: 4, HostName: "derp4h.tailscale.com"}, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + viper.Set("dns.base_domain", tt.baseDomain) + defer viper.Reset() + resetDerpRandomForTesting() + + testMap := tt.derpMap.View().AsStruct() + shuffleDERPMap(testMap) + + if diff := cmp.Diff(tt.expected, testMap); diff != "" { + t.Errorf("Shuffled DERP map doesn't match expected (-expected +actual):\n%s", diff) + } + }) + } + +} + +func TestShuffleDERPMapEdgeCases(t *testing.T) { + tests := []struct { + name string + derpMap *tailcfg.DERPMap + }{ + { + name: "nil derp map", + derpMap: nil, + }, + { + name: "empty derp map", + derpMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{}, + }, + }, + { + name: "region with no nodes", + derpMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + RegionID: 1, + RegionCode: "empty", + RegionName: "Empty Region", + Nodes: []*tailcfg.DERPNode{}, + }, + }, + }, + }, + { + name: "region with single node", + derpMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + RegionID: 1, + RegionCode: "single", + RegionName: "Single Node Region", + Nodes: []*tailcfg.DERPNode{ + {Name: "1a", RegionID: 1, HostName: "derp1a.tailscale.com"}, + }, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shuffleDERPMap(tt.derpMap) + }) + } +} + + +func TestShuffleDERPMapWithoutBaseDomain(t *testing.T) { + viper.Reset() + resetDerpRandomForTesting() + + derpMap := &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + RegionID: 1, + RegionCode: "test", + RegionName: "Test Region", + Nodes: []*tailcfg.DERPNode{ + {Name: "1a", RegionID: 1, HostName: "derp1a.test.com"}, + {Name: "1b", RegionID: 1, HostName: "derp1b.test.com"}, + {Name: "1c", RegionID: 1, HostName: "derp1c.test.com"}, + {Name: "1d", RegionID: 1, HostName: "derp1d.test.com"}, + }, + }, + }, + } + + original := derpMap.View().AsStruct() + shuffleDERPMap(derpMap) + + if len(derpMap.Regions) != 1 || len(derpMap.Regions[1].Nodes) != 4 { + t.Error("Shuffle corrupted DERP map structure") + } + + originalNodes := make(map[string]bool) + for _, node := range original.Regions[1].Nodes { + originalNodes[node.Name] = true + } + + shuffledNodes := make(map[string]bool) + for _, node := range derpMap.Regions[1].Nodes { + shuffledNodes[node.Name] = true + } + + if diff := cmp.Diff(originalNodes, shuffledNodes); diff != "" { + t.Errorf("Shuffle changed node set (-original +shuffled):\n%s", diff) + } +}