diff --git a/cmd/k8s-operator/depaware.txt b/cmd/k8s-operator/depaware.txt index a862509b4..8847a041b 100644 --- a/cmd/k8s-operator/depaware.txt +++ b/cmd/k8s-operator/depaware.txt @@ -792,7 +792,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/ tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+ tailscale.com/net/memnet from tailscale.com/tsnet tailscale.com/net/netaddr from tailscale.com/ipn+ - tailscale.com/net/netcheck from tailscale.com/ipn/ipnlocal+ + tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock tailscale.com/net/neterror from tailscale.com/net/dns/resolver+ tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal tailscale.com/net/netknob from tailscale.com/logpolicy+ diff --git a/cmd/tailscaled/depaware-min.txt b/cmd/tailscaled/depaware-min.txt index 2f1dd57b5..5d451f0da 100644 --- a/cmd/tailscaled/depaware-min.txt +++ b/cmd/tailscaled/depaware-min.txt @@ -95,7 +95,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/net/flowtrack from tailscale.com/wgengine/filter tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+ tailscale.com/net/netaddr from tailscale.com/ipn+ - tailscale.com/net/netcheck from tailscale.com/ipn/ipnlocal+ + tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock tailscale.com/net/neterror from tailscale.com/net/batching+ tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal tailscale.com/net/netknob from tailscale.com/logpolicy+ diff --git a/cmd/tailscaled/depaware-minbox.txt b/cmd/tailscaled/depaware-minbox.txt index 7e2f759a7..6f8be7832 100644 --- a/cmd/tailscaled/depaware-minbox.txt +++ b/cmd/tailscaled/depaware-minbox.txt @@ -112,7 +112,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/net/flowtrack from tailscale.com/wgengine/filter tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+ tailscale.com/net/netaddr from tailscale.com/ipn+ - tailscale.com/net/netcheck from tailscale.com/ipn/ipnlocal+ + tailscale.com/net/netcheck from tailscale.com/cmd/tailscale/cli+ tailscale.com/net/neterror from tailscale.com/net/batching+ tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal tailscale.com/net/netknob from tailscale.com/logpolicy+ diff --git a/cmd/tsidp/depaware.txt b/cmd/tsidp/depaware.txt index 1af72f703..c4660868d 100644 --- a/cmd/tsidp/depaware.txt +++ b/cmd/tsidp/depaware.txt @@ -191,7 +191,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+ tailscale.com/net/memnet from tailscale.com/tsnet tailscale.com/net/netaddr from tailscale.com/ipn+ - tailscale.com/net/netcheck from tailscale.com/ipn/ipnlocal+ + tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock tailscale.com/net/neterror from tailscale.com/net/dns/resolver+ tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal tailscale.com/net/netknob from tailscale.com/logpolicy+ diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index ced82a22a..2ed2663a4 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -58,7 +58,6 @@ "tailscale.com/net/dnscache" "tailscale.com/net/dnsfallback" "tailscale.com/net/ipset" - "tailscale.com/net/netcheck" "tailscale.com/net/netkernelconf" "tailscale.com/net/netmon" "tailscale.com/net/netns" @@ -8643,10 +8642,19 @@ func (b *LocalBackend) suggestExitNodeLocked() (response apitype.ExitNodeSuggest if !buildfeatures.HasUseExitNode { return response, feature.ErrUnavailable } - lastReport := b.MagicConn().GetLastNetcheckReport(b.ctx) + mc := b.MagicConn() + var preferredDERP int + if lastReport := mc.GetLastNetcheckReport(b.ctx); lastReport != nil { + preferredDERP = lastReport.PreferredDERP + } + // Use netcheck's recent per-region latency rather than only the latest report: + // the latest report may be an incremental netcheck that didn't re-probe the + // regions where far-away candidate exit nodes live, which would otherwise force + // a random choice. + regionLatency := mc.GetDERPRegionLatency() prevSuggestion := b.lastSuggestedExitNode - res, err := suggestExitNode(lastReport, b.currentNode(), prevSuggestion, randomRegion, randomNode, b.getAllowedSuggestions()) + res, err := suggestExitNode(preferredDERP, regionLatency, b.currentNode(), prevSuggestion, randomRegion, randomNode, b.getAllowedSuggestions()) if err != nil { return res, err } @@ -8718,7 +8726,11 @@ func fillAllowedSuggestions(polc policyclient.Client) (set.Set[tailcfg.StableNod // suggestExitNode returns a suggestion for reasonably good exit node based on // the current netmap and the previous suggestion. -func suggestExitNode(report *netcheck.Report, nb *nodeBackend, prevSuggestion tailcfg.StableNodeID, selectRegion selectRegionFunc, selectNode selectNodeFunc, allowList set.Set[tailcfg.StableNodeID]) (res apitype.ExitNodeSuggestionResponse, err error) { +// +// preferredDERP is this device's home DERP region (0 if unknown) and +// regionLatency is the best recent latency to each DERP region; both come from +// netcheck and are only used by the DERP-based algorithm. +func suggestExitNode(preferredDERP int, regionLatency map[int]time.Duration, nb *nodeBackend, prevSuggestion tailcfg.StableNodeID, selectRegion selectRegionFunc, selectNode selectNodeFunc, allowList set.Set[tailcfg.StableNodeID]) (res apitype.ExitNodeSuggestionResponse, err error) { switch { case nb.SelfHasCap(tailcfg.NodeAttrTrafficSteering): // The traffic-steering feature flag is enabled on this tailnet. @@ -8727,7 +8739,7 @@ func suggestExitNode(report *netcheck.Report, nb *nodeBackend, prevSuggestion ta // The control plane will always strip the `traffic-steering` // node attribute if it isn’t enabled for this tailnet, even if // it is set in the policy file: tailscale/corp#34401 - res, err = suggestExitNodeUsingDERP(report, nb, prevSuggestion, selectRegion, selectNode, allowList) + res, err = suggestExitNodeUsingDERP(preferredDERP, regionLatency, nb, prevSuggestion, selectRegion, selectNode, allowList) } if err != nil { nb.logf("netmap: suggested exit node: %v", err) @@ -8742,22 +8754,23 @@ func suggestExitNode(report *netcheck.Report, nb *nodeBackend, prevSuggestion ta // before traffic steering was implemented. This handles the plain failover // case, in addition to the optional Regional Routing. // -// It computes a suggestion based on the current netmap and last netcheck -// report. If there are multiple equally good options, one is selected at -// random, so the result is not stable. To be eligible for consideration, the -// peer must have NodeAttrSuggestExitNode in its CapMap. +// It computes a suggestion based on the current netmap, this device's preferred +// DERP region, and the recent measured latency to each DERP region (regionLatency). +// If there are multiple equally good options, one is selected at random, so the +// result is not stable. To be eligible for consideration, the peer must have +// NodeAttrSuggestExitNode in its CapMap. // // Currently, peers with a DERP home are preferred over those without (typically // this means Mullvad). Peers are selected based on having a DERP home that is // the lowest latency to this device. For peers without a DERP home, we look for // geographic proximity to this device's DERP home. -func suggestExitNodeUsingDERP(report *netcheck.Report, nb *nodeBackend, prevSuggestion tailcfg.StableNodeID, selectRegion selectRegionFunc, selectNode selectNodeFunc, allowList set.Set[tailcfg.StableNodeID]) (res apitype.ExitNodeSuggestionResponse, err error) { +func suggestExitNodeUsingDERP(preferredRegionID int, regionLatency map[int]time.Duration, nb *nodeBackend, prevSuggestion tailcfg.StableNodeID, selectRegion selectRegionFunc, selectNode selectNodeFunc, allowList set.Set[tailcfg.StableNodeID]) (res apitype.ExitNodeSuggestionResponse, err error) { // TODO(sfllaw): Context needs to be plumbed down here to support // reachability testing. ctx := context.TODO() netMap := nb.NetMap() - if report == nil || report.PreferredDERP == 0 || netMap == nil || netMap.DERPMap == nil { + if preferredRegionID == 0 || netMap == nil || netMap.DERPMap == nil { return res, ErrNoPreferredDERP } // Use [nodeBackend.AppendMatchingPeers] instead of the netmap directly, @@ -8788,7 +8801,7 @@ func suggestExitNodeUsingDERP(report *netcheck.Report, nb *nodeBackend, prevSugg } candidatesByRegion := make(map[int][]tailcfg.NodeView, len(netMap.DERPMap.Regions)) - preferredDERP, ok := netMap.DERPMap.Regions[report.PreferredDERP] + preferredDERP, ok := netMap.DERPMap.Regions[preferredRegionID] if !ok { return res, ErrNoPreferredDERP } @@ -8824,10 +8837,10 @@ type nodeDistance struct { } distances = append(distances, nodeDistance{nv: c, distance: distance}) } - // First, try to select an exit node that has the closest DERP home, based on lastReport's DERP latency. + // First, try to select an exit node that has the closest DERP home, based on recent DERP latency. // If there are no latency values, it returns an arbitrary region if len(candidatesByRegion) > 0 { - minRegion := minLatencyDERPRegion(slicesx.MapKeys(candidatesByRegion), report) + minRegion := minLatencyDERPRegion(slicesx.MapKeys(candidatesByRegion), regionLatency) if minRegion == 0 { minRegion = selectRegion(views.SliceOf(slicesx.MapKeys(candidatesByRegion))) } @@ -9016,16 +9029,16 @@ func randomNode(nodes views.Slice[tailcfg.NodeView], prefer tailcfg.StableNodeID return nodes.At(rand.IntN(nodes.Len())) } -// minLatencyDERPRegion returns the region with the lowest latency value given the last netcheck report. -// If there are no latency values, it returns 0. -func minLatencyDERPRegion(regions []int, report *netcheck.Report) int { +// minLatencyDERPRegion returns the region with the lowest latency value given +// the per-region latency map. If there are no latency values, it returns 0. +func minLatencyDERPRegion(regions []int, regionLatency map[int]time.Duration) int { min := slices.MinFunc(regions, func(i, j int) int { const largeDuration time.Duration = math.MaxInt64 - iLatency, ok := report.RegionLatency[i] + iLatency, ok := regionLatency[i] if !ok { iLatency = largeDuration } - jLatency, ok := report.RegionLatency[j] + jLatency, ok := regionLatency[j] if !ok { jLatency = largeDuration } @@ -9034,7 +9047,7 @@ func minLatencyDERPRegion(regions []int, report *netcheck.Report) int { } return cmp.Compare(i, j) }) - latency, ok := report.RegionLatency[min] + latency, ok := regionLatency[min] if !ok || latency == 0 { return 0 } else { diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 0078d6ed9..79d49249e 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -1421,9 +1421,11 @@ func TestConfigureExitNode(t *testing.T) { lb := newTestLocalBackendWithSys(t, sys) lb.SetPrefsForTest(tt.prefs.Clone()) - // Then set the netcheck report and netmap, if any. + // Then set the netcheck report and netmap, if any. Clone the shared + // report because AddNetcheckReportForTest mutates it and subtests run + // in parallel. if tt.report != nil { - lb.MagicConn().SetLastNetcheckReportForTest(t.Context(), tt.report) + lb.MagicConn().AddNetcheckReportForTest(clientNetmap.DERPMap, tt.report, time.Now()) } if tt.netMap != nil { lb.SetControlClientStatus(lb.cc, controlclient.Status{NetMap: tt.netMap}) @@ -1681,7 +1683,7 @@ func TestExitNodeNotifyOrder(t *testing.T) { clientNetmap := buildNetmapWithPeers(selfNode, exitNode1, exitNode2) lb := newTestLocalBackend(t) - lb.sys.MagicSock.Get().SetLastNetcheckReportForTest(lb.ctx, report) + lb.sys.MagicSock.Get().AddNetcheckReportForTest(clientNetmap.DERPMap, report, time.Now()) lb.SetPrefsForTest(&ipn.Prefs{ ControlURL: controlURL, AutoExitNode: ipn.AnyExitNode, @@ -4090,7 +4092,7 @@ func TestUpdateNetmapDeltaAutoExitNode(t *testing.T) { b := newTestLocalBackendWithSys(t, sys) b.currentNode().SetNetMap(tt.netmap) b.lastSuggestedExitNode = tt.lastSuggestedExitNode - b.sys.MagicSock.Get().SetLastNetcheckReportForTest(b.ctx, tt.report) + b.sys.MagicSock.Get().AddNetcheckReportForTest(derpMap, tt.report, time.Now()) b.SetPrefsForTest(b.pm.CurrentPrefs().AsStruct()) allDone := make(chan bool, 1) @@ -4224,14 +4226,14 @@ func TestAutoExitNodeSetNetInfoCallback(t *testing.T) { t.Errorf("got initial exit node %v, want %v", eid, peer1.StableID()) } b.refreshAutoExitNode = true - b.sys.MagicSock.Get().SetLastNetcheckReportForTest(b.ctx, &netcheck.Report{ + b.sys.MagicSock.Get().AddNetcheckReportForTest(defaultDERPMap, &netcheck.Report{ RegionLatency: map[int]time.Duration{ 1: 10 * time.Millisecond, 2: 5 * time.Millisecond, 3: 30 * time.Millisecond, }, PreferredDERP: 2, - }) + }, time.Now()) b.setNetInfo(&ni) if eid := b.Prefs().ExitNodeID(); eid != peer2.StableID() { t.Errorf("got final exit node %v, want %v", eid, peer2.StableID()) @@ -4288,7 +4290,7 @@ func TestSetControlClientStatusAutoExitNode(t *testing.T) { // Peer 2 should be the initial exit node, as it's better than peer 1 // in terms of latency and DERP region. b.lastSuggestedExitNode = peer2.StableID() - b.sys.MagicSock.Get().SetLastNetcheckReportForTest(b.ctx, report) + b.sys.MagicSock.Get().AddNetcheckReportForTest(derpMap, report, time.Now()) b.SetPrefsForTest(b.pm.CurrentPrefs().AsStruct()) offlinePeer2 := makePeer(2, withCap(26), withSuggest(), withExitRoutes(), withOnline(false), withNodeKey()) updatedNetmap := &netmap.NetworkMap{ @@ -6058,7 +6060,14 @@ func TestSuggestExitNode(t *testing.T) { defer nb.shutdown(errShutdown) nb.SetNetMap(tt.netMap) - got, err := suggestExitNode(tt.lastReport, nb, tt.lastSuggestion, selectRegion, selectNode, allowList) + var preferredDERP int + var regionLatency map[int]time.Duration + if tt.lastReport != nil { + preferredDERP = tt.lastReport.PreferredDERP + regionLatency = tt.lastReport.RegionLatency + } + + got, err := suggestExitNode(preferredDERP, regionLatency, nb, tt.lastSuggestion, selectRegion, selectNode, allowList) if got.Name != tt.wantName { t.Errorf("name=%v, want %v", got.Name, tt.wantName) } @@ -6078,6 +6087,89 @@ func TestSuggestExitNode(t *testing.T) { } } +// TestSuggestExitNodeUsesRecentDERPLatency exercises DERP latency-based exit node +// suggestion when the most recent netcheck report is incremental. +func TestSuggestExitNodeUsesRecentDERPLatency(t *testing.T) { + t.Parallel() + + derpMap := &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: {Nodes: []*tailcfg.DERPNode{{Name: "1a", RegionID: 1}}}, + 2: {Nodes: []*tailcfg.DERPNode{{Name: "2a", RegionID: 2}}}, + 3: {Nodes: []*tailcfg.DERPNode{{Name: "3a", RegionID: 3}}}, + 4: {Nodes: []*tailcfg.DERPNode{{Name: "4a", RegionID: 4}}}, + 5: {Nodes: []*tailcfg.DERPNode{{Name: "5a", RegionID: 5}}}, + }, + } + + // Two candidate exit nodes, each homed in a far region (4 and 5) that the most + // recent (incremental) netcheck won't re-probe. + exitRegion4 := makePeer(4, withDERP(4), withExitRoutes(), withSuggest()) + exitRegion5 := makePeer(5, withDERP(5), withExitRoutes(), withSuggest()) + + selfNode := tailcfg.Node{ + Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.1.1/32")}, + } + netMap := &netmap.NetworkMap{ + SelfNode: selfNode.View(), + DERPMap: derpMap, + Peers: []tailcfg.NodeView{exitRegion4, exitRegion5}, + } + + // A full netcheck measured every region: region 4 (100ms) is closer than + // region 5 (200ms). + fullReport := &netcheck.Report{ + PreferredDERP: 1, + RegionLatency: map[int]time.Duration{ + 1: 10 * time.Millisecond, + 2: 20 * time.Millisecond, + 3: 30 * time.Millisecond, + 4: 100 * time.Millisecond, + 5: 200 * time.Millisecond, + }, + } + // A later incremental netcheck only re-probed the home and fastest regions, so + // it has no latency for regions 4 or 5. + incrementalReport := &netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 10 * time.Millisecond, + 2: 20 * time.Millisecond, + 3: 30 * time.Millisecond, + }, + } + + b := newTestLocalBackend(t) + b.currentNode().SetNetMap(netMap) + mc := b.sys.MagicSock.Get() + + // Without any netcheck reports, SuggestExitNode returns an error because no + // preferred DERP can be determined. + if _, err := b.SuggestExitNode(); !errors.Is(err, ErrNoPreferredDERP) { + t.Fatalf("SuggestExitNode() error = %v, want ErrNoPreferredDERP", err) + } + + // Seed the full report, then the incremental one a minute later, so both + // remain in netcheck's recent history. + now := time.Now() + mc.AddNetcheckReportForTest(derpMap, fullReport, now.Add(-time.Minute)) + mc.AddNetcheckReportForTest(derpMap, incrementalReport, now) + + // suggestExitNodeLocked falls back to a random region when it cannot order the + // candidates by latency, so query repeatedly to make sure it always returns the closer region-4 node. + const iterations = 64 + got := make(map[tailcfg.StableNodeID]int) + for range iterations { + res, err := b.SuggestExitNode() + if err != nil { + t.Fatalf("SuggestExitNode() error = %v", err) + } + got[res.ID]++ + } + if len(got) != 1 || got[exitRegion4.StableID()] != iterations { + t.Errorf("expected all %v suggestions to be for %v, got %+v", iterations, exitRegion4.StableID(), got) + } +} + func TestSuggestExitNodePickWeighted(t *testing.T) { location10 := tailcfg.Location{ Priority: 10, @@ -6565,46 +6657,41 @@ func TestSuggestExitNodeTrafficSteering(t *testing.T) { func TestMinLatencyDERPregion(t *testing.T) { tests := []struct { - name string - regions []int - report *netcheck.Report - wantRegion int + name string + regions []int + regionLatency map[int]time.Duration + wantRegion int }{ { name: "regions-no-latency", regions: []int{1, 2, 3}, wantRegion: 0, - report: &netcheck.Report{}, }, { name: "regions-different-latency", regions: []int{1, 2, 3}, wantRegion: 2, - report: &netcheck.Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 5 * time.Millisecond, - 3: 30 * time.Millisecond, - }, + regionLatency: map[int]time.Duration{ + 1: 10 * time.Millisecond, + 2: 5 * time.Millisecond, + 3: 30 * time.Millisecond, }, }, { name: "regions-same-latency", regions: []int{1, 2, 3}, wantRegion: 1, - report: &netcheck.Report{ - RegionLatency: map[int]time.Duration{ - 1: 10 * time.Millisecond, - 2: 10 * time.Millisecond, - 3: 10 * time.Millisecond, - }, + regionLatency: map[int]time.Duration{ + 1: 10 * time.Millisecond, + 2: 10 * time.Millisecond, + 3: 10 * time.Millisecond, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := minLatencyDERPRegion(tt.regions, tt.report) + got := minLatencyDERPRegion(tt.regions, tt.regionLatency) if got != tt.wantRegion { t.Errorf("got region %v want region %v", got, tt.wantRegion) } diff --git a/net/netcheck/netcheck.go b/net/netcheck/netcheck.go index a64c358c5..b33fd1458 100644 --- a/net/netcheck/netcheck.go +++ b/net/netcheck/netcheck.go @@ -45,6 +45,7 @@ "tailscale.com/types/views" "tailscale.com/util/clientmetric" "tailscale.com/util/mak" + "tailscale.com/util/testenv" ) // Debugging and experimentation tweakables. @@ -1352,6 +1353,26 @@ func (c *Client) timeNow() time.Time { PreferredDERPKeepAliveTimeout = 2 * derp.KeepAlive ) +// addReportAndPruneExpired adds r to the set of recent Reports, and drops +// reports that are outside of the retention window. +func (c *Client) addReportAndPruneExpired(now time.Time, r *Report) { + if c.prev == nil { + c.prev = map[time.Time]*Report{} + } + r.Now = now.UTC() + c.prev[now] = r + c.last = r + + // maxAge is the retention window for report history, based on fullReportInterval + // to make sure that at least one full report is always retained. + const maxAge = fullReportInterval + ReportTimeout + for t := range c.prev { + if now.Sub(t) > maxAge { + delete(c.prev, t) + } + } +} + // addReportHistoryAndSetPreferredDERP adds r to the set of recent Reports // and mutates r.PreferredDERP to contain the best recent one. func (c *Client) addReportHistoryAndSetPreferredDERP(rs *reportState, r *Report, dm tailcfg.DERPMapView) { @@ -1362,30 +1383,12 @@ func (c *Client) addReportHistoryAndSetPreferredDERP(rs *reportState, r *Report, if c.last != nil { prevDERP = c.last.PreferredDERP } - if c.prev == nil { - c.prev = map[time.Time]*Report{} - } + + // Add report to history, enforce retention window, then take the best (lowest) + // latency seen per region across what remains. now := c.timeNow() - r.Now = now.UTC() - c.prev[now] = r - c.last = r - - const maxAge = 5 * time.Minute - - // region ID => its best recent latency in last maxAge - bestRecent := map[int]time.Duration{} - - for t, pr := range c.prev { - if now.Sub(t) > maxAge { - delete(c.prev, t) - continue - } - for regionID, d := range pr.RegionLatency { - if bd, ok := bestRecent[regionID]; !ok || d < bd { - bestRecent[regionID] = d - } - } - } + c.addReportAndPruneExpired(now, r) + bestRecent := c.bestRecentLatencyLocked() // Scale each region's best latency by any provided scores from the // DERPMap, for use in comparison below. @@ -1480,6 +1483,40 @@ func (c *Client) addReportHistoryAndSetPreferredDERP(rs *reportState, r *Report, } } +// bestRecentLatencyLocked returns the lowest latency seen per DERP region across +// the reports currently retained in history (c.prev), keyed by region ID. These +// latencies are used for determining preferred DERP and suggesting an exit node. +func (c *Client) bestRecentLatencyLocked() map[int]time.Duration { + best := make(map[int]time.Duration) + for _, pr := range c.prev { + for regionID, d := range pr.RegionLatency { + if bd, ok := best[regionID]; !ok || d < bd { + best[regionID] = d + } + } + } + return best +} + +// RecentRegionLatency returns the lowest latency seen per DERP region over the +// recent history window, keyed by region ID. +func (c *Client) RecentRegionLatency() map[int]time.Duration { + c.mu.Lock() + defer c.mu.Unlock() + return c.bestRecentLatencyLocked() +} + +// AddReportHistoryForTest records r in the client's recent-report history +// (prev/last) as if GetReport had produced it at time now, and recomputes +// r.PreferredDERP from that history. +func (c *Client) AddReportHistoryForTest(dm *tailcfg.DERPMap, r *Report, now time.Time) { + testenv.AssertInTest() + defer func(prev func() time.Time) { c.TimeNow = prev }(c.TimeNow) + c.TimeNow = func() time.Time { return now } + rs := &reportState{c: c, start: now} + c.addReportHistoryAndSetPreferredDERP(rs, r, dm.View()) +} + func updateLatency(m map[int]time.Duration, regionID int, d time.Duration) { if prev, ok := m[regionID]; !ok || d < prev { m[regionID] = d diff --git a/tsnet/depaware.txt b/tsnet/depaware.txt index f7afe97bb..26448c37b 100644 --- a/tsnet/depaware.txt +++ b/tsnet/depaware.txt @@ -187,7 +187,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware) tailscale.com/net/ipset from tailscale.com/ipn/ipnlocal+ tailscale.com/net/memnet from tailscale.com/tsnet tailscale.com/net/netaddr from tailscale.com/ipn+ - tailscale.com/net/netcheck from tailscale.com/ipn/ipnlocal+ + tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock tailscale.com/net/neterror from tailscale.com/net/dns/resolver+ tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal tailscale.com/net/netknob from tailscale.com/logpolicy+ diff --git a/wgengine/magicsock/magicsock.go b/wgengine/magicsock/magicsock.go index 704a45f1f..d33db5f8f 100644 --- a/wgengine/magicsock/magicsock.go +++ b/wgengine/magicsock/magicsock.go @@ -4345,10 +4345,35 @@ func (c *Conn) GetLastNetcheckReport(ctx context.Context) *netcheck.Report { return c.lastNetCheckReport.Load() } -// SetLastNetcheckReportForTest sets the magicsock conn's last netcheck report. -// Used for testing purposes. -func (c *Conn) SetLastNetcheckReportForTest(ctx context.Context, report *netcheck.Report) { - c.lastNetCheckReport.Store(report) +// AddNetcheckReportForTest records report in the conn's netcheck client's +// recent-report history as if it had been produced at time now, seeding the +// netcheck client's per-region latency history. If report is newer than the +// currently stored last netcheck report, it also becomes the last netcheck +// report. +func (c *Conn) AddNetcheckReportForTest(dm *tailcfg.DERPMap, report *netcheck.Report, now time.Time) { + testenv.AssertInTest() + rep := report.Clone() // netchecker mutates the report, so create a copy + c.netChecker.AddReportHistoryForTest(dm, rep, now) + for { + if cur := c.lastNetCheckReport.Load(); cur == nil || rep.Now.After(cur.Now) { + if c.lastNetCheckReport.CompareAndSwap(cur, rep) { + break + } + } + } +} + +// GetDERPRegionLatency returns the lowest latency seen per DERP region over +// netcheck's recent history, keyed by region ID. Unlike the most recent report +// from GetLastNetcheckReport (which for an incremental netcheck covers only a +// few regions), netcheck's history retains every region measured by the most +// recent full netcheck, so this can rank regions the latest report did not +// re-probe. It returns nil if the netcheck client is not yet initialized. +func (c *Conn) GetDERPRegionLatency() map[int]time.Duration { + if c.netChecker == nil { + return nil + } + return c.netChecker.RecentRegionLatency() } // lazyEndpoint is a wireguard [conn.Endpoint] for when magicsock received a