diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go index 6458f510b3a13..bda622d96249e 100644 --- a/client/tailscale/apitype/apitype.go +++ b/client/tailscale/apitype/apitype.go @@ -49,3 +49,9 @@ type ReloadConfigResponse struct { Reloaded bool // whether the config was reloaded Err string // any error message } + +type ExitNodeSuggestionResponse struct { + SuggestedExitNodeID tailcfg.StableNodeID + SuggestedExitNodeName string + SuggestedExitNodeLocation tailcfg.Location +} diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 533f35330200d..46965fc28b455 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -1505,3 +1505,16 @@ func (w *IPNBusWatcher) Next() (ipn.Notify, error) { } return n, nil } + +// SuggestExitNode returns the tailcfg.StableNodeID, name, and tailcfg.Location of a suggested exit node to connect to. +func (lc *LocalClient) SuggestExitNode(ctx context.Context) (tailcfg.StableNodeID, string, tailcfg.Location, error) { + body, err := lc.send(ctx, "POST", "/localapi/v0/suggest-exit-node", 200, nil) + if err != nil { + return "", "", tailcfg.Location{}, fmt.Errorf("error %w: %s", err, body) + } + res, err := decodeJSON[apitype.ExitNodeSuggestionResponse](body) + if err != nil { + return "", "", tailcfg.Location{}, err + } + return res.SuggestedExitNodeID, res.SuggestedExitNodeName, res.SuggestedExitNodeLocation, nil +} diff --git a/cmd/tailscale/cli/exitnode.go b/cmd/tailscale/cli/exitnode.go index 77e2453d0761c..56bdac767f6ea 100644 --- a/cmd/tailscale/cli/exitnode.go +++ b/cmd/tailscale/cli/exitnode.go @@ -37,6 +37,16 @@ var exitNodeCmd = &ffcli.Command{ return fs })(), }, + { + Name: "suggest", + ShortUsage: "exit-node suggest", + ShortHelp: "Picks the best available exit node", + Exec: runExitNodeSuggest, + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("suggest") + return fs + })(), + }, }, Exec: func(context.Context, []string) error { return errors.New("exit-node subcommand required; run 'tailscale exit-node -h' for details") @@ -98,7 +108,22 @@ func runExitNodeList(ctx context.Context, args []string) error { fmt.Fprintln(w) fmt.Fprintln(w) fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP") + fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP. To have Tailscale recommend an exit node, use `tailscale exit-node suggest`.") + return nil +} +// runExitNodeSuggest returns a suggested exit node ID to connect to and shows the chosen exit node tailcfg.StableNodeID. +// If there are no derp based exit nodes to choose from or there is a failure in finding a suggestion, the command will return an error indicating so. +func runExitNodeSuggest(ctx context.Context, args []string) error { + suggestedNodeID, suggestedNodeName, _, err := localClient.SuggestExitNode(ctx) + if err != nil { + return fmt.Errorf("Failed to suggest exit node. Error: %v", err) + } + if suggestedNodeID == "" { + fmt.Println("Unable to suggest an exit node") + } else { + fmt.Printf("Suggested exit node id: %v, name: %v To set as exit node run `tailscale set --exit-node=`.\n", suggestedNodeID, suggestedNodeName) + } return nil } diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index 83afbcd2e4960..400b75a7e0e84 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -292,7 +292,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/net/flowtrack from tailscale.com/net/packet+ 💣 tailscale.com/net/interfaces from tailscale.com/cmd/tailscaled+ tailscale.com/net/netaddr from tailscale.com/ipn+ - tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock + 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 a83ebdce3f8ac..298007ac1f74b 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -59,6 +59,7 @@ import ( "tailscale.com/net/dnscache" "tailscale.com/net/dnsfallback" "tailscale.com/net/interfaces" + "tailscale.com/net/netcheck" "tailscale.com/net/netkernelconf" "tailscale.com/net/netmon" "tailscale.com/net/netns" @@ -6203,3 +6204,157 @@ func mayDeref[T any](p *T) (v T) { } return *p } + +var errNoSuggestion = errors.New("no suggestion available") +var errUnableToPick = errors.New("unable to pick candidate") + +// SuggestExitNode returns a tailcfg.StableNodeID, name, and tailcfg.Location of a suggested exit node given the local backend's netmap and last report. +// Non-mullvad nodes are prioritized before suggesting Mullvad nodes. +// We look at non-mullvad nodes region latency values, and if there are none then we choose the geographic closest Mullvad node to the self node's preferred DERP region. +func (b *LocalBackend) SuggestExitNode() (tailcfg.StableNodeID, string, tailcfg.Location, error) { + b.mu.Lock() + lastReport := b.MagicConn().GetLastNetcheckReport(b.ctx) + netMap := b.netMap + b.mu.Unlock() + return suggestExitNode(lastReport, netMap) +} + +func suggestExitNode(lastReport *netcheck.Report, netMap *netmap.NetworkMap) (tailcfg.StableNodeID, string, tailcfg.Location, error) { + var preferredExitNodeID tailcfg.StableNodeID + var preferredExitNodeName string + var preferredExitNodeLocation tailcfg.Location + if lastReport.PreferredDERP == 0 { + return preferredExitNodeID, "", tailcfg.Location{}, errUnableToPick + } + var candidates []tailcfg.NodeView + peers := netMap.Peers + for _, peer := range peers { + if peer.CapMap().Has(tailcfg.NodeAttrSuggestExitNode) { + if online := peer.Online(); online != nil && !*online { + continue + } + if tsaddr.ContainsExitRoutes(peer.AllowedIPs()) { + candidates = append(candidates, peer) + } + } + } + if len(candidates) == 1 { + if candidates[0].Valid() { + hi := candidates[0].Hostinfo() + if hi.Valid() && hi.Location().View().Valid() { + preferredExitNodeLocation = *hi.Location() + } + return candidates[0].StableID(), candidates[0].Name(), preferredExitNodeLocation, nil + } else { + return preferredExitNodeID, "", tailcfg.Location{}, errUnableToPick + } + } + if len(candidates) == 0 { + return preferredExitNodeID, "", tailcfg.Location{}, errNoSuggestion + } + + candidateLatencyMap := make(map[int][]tailcfg.NodeView, len(netMap.DERPMap.Regions)) + candidateDistanceMap := make(map[float64][]tailcfg.NodeView, len(candidates)) + preferredDerp := netMap.DERPMap.Regions[lastReport.PreferredDERP] + sortedRegions := make([]int, 0, len(netMap.DERPMap.Regions)) + distances := make([]float64, 0, len(candidates)) + if preferredDerp == nil { + return preferredExitNodeID, "", tailcfg.Location{}, errUnableToPick + } + derpHomeCoordinates := []float64{preferredDerp.Latitude, preferredDerp.Longitude} + for _, candidate := range candidates { + if candidate.Valid() { + if candidate.DERP() != "" { + ipp, err := netip.ParseAddrPort(candidate.DERP()) + if err != nil { + continue + } + if ipp.Addr() == tailcfg.DerpMagicIPAddr { + regionID := int(ipp.Port()) + if !slices.Contains(sortedRegions, regionID) { + sortedRegions = append(sortedRegions, regionID) + } + candidateLatencyMap[regionID] = append(candidateLatencyMap[regionID], candidate) + } + } else { + if candidate.Hostinfo().Location().View().Valid() { + candidateCoordinates := []float64{candidate.Hostinfo().Location().Latitude, candidate.Hostinfo().Location().Longitude} + distance := longLatDistance(derpHomeCoordinates, candidateCoordinates) + candidateDistanceMap[distance] = append(candidateDistanceMap[distance], candidate) + distances = append(distances, distance) + } + } + } + } + sortedRegions = sortRegions(sortedRegions, lastReport) + if len(sortedRegions) > 0 && candidateLatencyMap[sortedRegions[0]] != nil && candidateLatencyMap[sortedRegions[0]][0].Valid() { + preferredExitNodeID = candidateLatencyMap[sortedRegions[0]][0].StableID() + preferredExitNodeName = candidateLatencyMap[sortedRegions[0]][0].Name() + hi := candidateLatencyMap[sortedRegions[0]][0].Hostinfo() + if hi.Valid() && hi.Location().View().Valid() { + preferredExitNodeLocation = *hi.Location() + } + } else { + slices.Sort(distances) + if len(distances) > 0 { + if len(candidateDistanceMap[distances[0]]) == 1 && candidateDistanceMap[distances[0]][0].Valid() { + preferredExitNodeID = candidateDistanceMap[distances[0]][0].StableID() + preferredExitNodeName = candidateDistanceMap[distances[0]][0].Name() + preferredExitNodeLocation = *candidateDistanceMap[distances[0]][0].Hostinfo().Location() + } else { + chosen := pickWeighted(candidateDistanceMap[distances[0]]) + if chosen.Valid() { + preferredExitNodeID = chosen.StableID() + preferredExitNodeName = chosen.Name() + preferredExitNodeLocation = *chosen.Hostinfo().Location() + } + } + } + } + if preferredExitNodeID.IsZero() { + return preferredExitNodeID, "", preferredExitNodeLocation, errNoSuggestion + } + return preferredExitNodeID, preferredExitNodeName, preferredExitNodeLocation, nil +} + +func pickWeighted(candidates []tailcfg.NodeView) tailcfg.NodeView { + maxWeight := 0 + var chosenCandidate tailcfg.NodeView + for _, candidate := range candidates { + if candidate.Hostinfo().Location().View().Valid() && candidate.Hostinfo().Location().Priority > maxWeight { + maxWeight = candidate.Hostinfo().Location().Priority + chosenCandidate = candidate + } + } + return chosenCandidate +} + +// sortRegions returns a list of sorted regions by ascending latency given a list of region IDs and a netcheck report. +func sortRegions(regions []int, lastReport *netcheck.Report) []int { + sort.Slice(regions, func(i, j int) bool { + iLatency, ok := lastReport.RegionLatency[regions[i]] + if !ok || iLatency == 0 { + iLatency = math.MaxInt + } + jLatency, ok := lastReport.RegionLatency[regions[j]] + if !ok || jLatency == 0 { + jLatency = math.MaxInt + } + return iLatency < jLatency + }) + return regions +} + +// longLatDistance returns an estimated distance given two lists of float64 values that represent geographic coordinates. +// The first index is latitude and the second index is longitude. +// Value is returned in meters. We tolerate up to 100km difference in accuracy. +func longLatDistance(firstDistance []float64, secondDistance []float64) float64 { + diffLat := (firstDistance[0] - secondDistance[0]) * math.Pi / 180 + diffLong := (firstDistance[1] - secondDistance[1]) * math.Pi / 180 + latRadians1 := firstDistance[0] * math.Pi / 180 + latRadians2 := secondDistance[0] * math.Pi / 180 + a := math.Pow(math.Sin(diffLat/2), 2) + math.Cos(latRadians1)*math.Cos(latRadians2)*math.Pow(math.Sin(diffLong/2), 2) + earthRadius := float64(6371000) // earth radius is 6371000 meters + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return earthRadius * c +} diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 0119bc7ac5f60..5e1718e031267 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "net" "net/http" "net/netip" @@ -29,6 +30,7 @@ import ( "tailscale.com/ipn" "tailscale.com/ipn/store/mem" "tailscale.com/net/interfaces" + "tailscale.com/net/netcheck" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" "tailscale.com/tsd" @@ -2627,3 +2629,581 @@ func TestRoundTraffic(t *testing.T) { } } + +func TestSuggestExitNode(t *testing.T) { + tests := []struct { + name string + lastReport netcheck.Report + netMap netmap.NetworkMap + wantID tailcfg.StableNodeID + wantName string + wantLocation tailcfg.Location + wantError error + }{ + { + name: "2 exit nodes in same region", + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 10 * time.Millisecond, + 2: 20 * time.Millisecond, + 3: 30 * time.Millisecond, + }, + PreferredDERP: 1, + }, + netMap: netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.1.1/32"), + netip.MustParsePrefix("fe70::1/128"), + }, + }).View(), + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: {}, + 2: {}, + 3: {}, + }, + }, + Peers: []tailcfg.NodeView{ + (&tailcfg.Node{ + ID: 2, + Name: "2", + StableID: "2", + DERP: "127.3.3.40:1", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ + tailcfg.NodeAttrSuggestExitNode: {}, + }), + }).View(), + (&tailcfg.Node{ + ID: 3, + Name: "3", + StableID: "3", + DERP: "127.3.3.40:1", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ + tailcfg.NodeAttrSuggestExitNode: {}, + }), + }).View(), + }, + }, + wantName: "2", + wantID: tailcfg.StableNodeID("2"), + }, + { + name: "2 derp based exit nodes, different regions, no latency measurements", + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 0, + 2: 0, + 3: 0, + }, + PreferredDERP: 1, + }, + netMap: netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.1.1/32"), + netip.MustParsePrefix("fe70::1/128"), + }, + }).View(), + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: {}, + 2: {}, + 3: {}, + }, + }, + Peers: []tailcfg.NodeView{ + (&tailcfg.Node{ + ID: 2, + StableID: "2", + Name: "2", + DERP: "127.3.3.40:1", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ + tailcfg.NodeAttrSuggestExitNode: {}, + }), + }).View(), + (&tailcfg.Node{ + ID: 3, + StableID: "3", + Name: "3", + DERP: "127.3.3.40:2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ + tailcfg.NodeAttrSuggestExitNode: {}, + }), + }).View(), + }, + }, + wantName: "2", + wantID: tailcfg.StableNodeID("2"), + }, + { + name: "mullvad nodes, no derp based exit nodes", + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 0, + 2: 0, + 3: 0, + }, + PreferredDERP: 1, + }, + netMap: netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.1.1/32"), + netip.MustParsePrefix("fe70::1/128"), + }, + }).View(), + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + Latitude: 40.73061, + Longitude: -73.935242, + }, + 2: {}, + 3: {}, + }, + }, + Peers: []tailcfg.NodeView{ + (&tailcfg.Node{ + ID: 2, + StableID: "2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + Name: "Dallas", + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Latitude: 32.89748, + Longitude: -97.040443, + }, + }).View(), + CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ + tailcfg.NodeAttrSuggestExitNode: {}, + }), + }).View(), + (&tailcfg.Node{ + ID: 3, + StableID: "3", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + Name: "SanJose", + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Latitude: 37.3382082, + Longitude: -121.8863286, + }, + }).View(), + CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ + tailcfg.NodeAttrSuggestExitNode: {}, + }), + }).View(), + }, + }, + wantID: tailcfg.StableNodeID("2"), + wantLocation: tailcfg.Location{ + Latitude: 32.89748, + Longitude: -97.040443, + }, + wantName: "Dallas", + }, + { + name: "mullvad nodes, no preferred derp region exit nodes", + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 0, + 2: 0, + 3: 0, + }, + PreferredDERP: 1, + }, + netMap: netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.1.1/32"), + netip.MustParsePrefix("fe70::1/128"), + }, + }).View(), + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + Latitude: 40.73061, + Longitude: -73.935242, + }, + 2: {}, + 3: {}, + }, + }, + Peers: []tailcfg.NodeView{ + (&tailcfg.Node{ + ID: 2, + StableID: "2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + Name: "Dallas", + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Latitude: 32.89748, + Longitude: -97.040443, + }, + }).View(), + CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ + tailcfg.NodeAttrSuggestExitNode: {}, + }), + }).View(), + (&tailcfg.Node{ + ID: 3, + StableID: "3", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + Name: "SanJose", + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Latitude: 37.3382082, + Longitude: -121.8863286, + }, + }).View(), + CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ + tailcfg.NodeAttrSuggestExitNode: {}, + }), + }).View(), + (&tailcfg.Node{ + ID: 3, + StableID: "3", + Name: "3", + DERP: "127.3.3.40:2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ + tailcfg.NodeAttrSuggestExitNode: {}, + }), + }).View(), + }, + }, + wantID: tailcfg.StableNodeID("3"), + wantName: "3", + }, + { + name: "no mullvad nodes; no derp nodes", + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 0, + 2: 0, + 3: 0, + }, + PreferredDERP: 1, + }, + netMap: netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.1.1/32"), + netip.MustParsePrefix("fe70::1/128"), + }, + }).View(), + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: {}, + 2: {}, + 3: {}, + }, + }, + }, + wantError: errNoSuggestion, + }, + { + name: "no preferred derp region", + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 0, + 2: 0, + 3: 0, + }, + }, + netMap: netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.1.1/32"), + netip.MustParsePrefix("fe70::1/128"), + }, + }).View(), + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: {}, + 2: {}, + 3: {}, + }, + }, + }, + wantError: errUnableToPick, + }, + { + name: "derp exit node and mullvad exit node both with no suggest exit node attribute", + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 0, + 2: 0, + 3: 0, + }, + PreferredDERP: 1, + }, + netMap: netmap.NetworkMap{ + SelfNode: (&tailcfg.Node{ + Addresses: []netip.Prefix{ + netip.MustParsePrefix("100.64.1.1/32"), + netip.MustParsePrefix("fe70::1/128"), + }, + }).View(), + DERPMap: &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: {}, + 2: {}, + 3: {}, + }, + }, + Peers: []tailcfg.NodeView{ + (&tailcfg.Node{ + ID: 2, + StableID: "2", + Name: "2", + DERP: "127.3.3.40:1", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + }).View(), + (&tailcfg.Node{ + ID: 2, + StableID: "2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + Name: "Dallas", + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Latitude: 32.89748, + Longitude: -97.040443, + }, + }).View(), + }).View(), + }, + }, + wantError: errNoSuggestion, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID, gotName, gotLocation, err := suggestExitNode(&tt.lastReport, &tt.netMap) + if gotName != tt.wantName || gotID != tt.wantID || err != tt.wantError || gotLocation != tt.wantLocation { + t.Errorf("got name %v id %v location %v error %v want name %v id %v location %v error %v", gotName, gotID, gotLocation, err, tt.wantName, tt.wantID, tt.wantLocation, tt.wantError) + } + }) + } +} + +func TestSuggestExitNodeSortRegions(t *testing.T) { + tests := []struct { + name string + regions []int + lastReport netcheck.Report + wantValue []int + }{ + { + name: "list of regions and netcheck report has latency values", + regions: []int{1, 3, 5}, + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 3, + 3: 2, + 5: 1, + }, + }, + wantValue: []int{5, 3, 1}, + }, + { + name: "empty list of regions", + regions: []int{}, + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{}, + }, + wantValue: []int{}, + }, + { + name: "list of regions and netcheck report doesn't have all regions' values", + regions: []int{1, 3, 5}, + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 0, + 3: 1, + 5: 0, + }, + }, + wantValue: []int{3, 1, 5}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sortRegions(tt.regions, &tt.lastReport) + if !reflect.DeepEqual(got, tt.wantValue) { + t.Errorf("got value %v want %v", got, tt.wantValue) + } + }) + } +} + +func TestSuggestExitNodePickWeighted(t *testing.T) { + tests := []struct { + name string + candidates []tailcfg.NodeView + wantValue tailcfg.NodeView + wantValid bool + }{ + { + name: ">1 candidates", + candidates: []tailcfg.NodeView{ + (&tailcfg.Node{ + ID: 2, + StableID: "2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Priority: 20, + }, + }).View(), + }).View(), + (&tailcfg.Node{ + ID: 3, + StableID: "3", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Priority: 10, + }, + }).View(), + }).View(), + }, + wantValue: (&tailcfg.Node{ + ID: 2, + StableID: "2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Priority: 20, + }, + }).View(), + }).View(), + wantValid: true, + }, + { + name: "<1 candidates", + candidates: []tailcfg.NodeView{}, + wantValid: false, + }, + { + name: "1 candidate", + candidates: []tailcfg.NodeView{ + (&tailcfg.Node{ + ID: 2, + StableID: "2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Priority: 20, + }, + }).View(), + }).View(), + }, + wantValue: (&tailcfg.Node{ + ID: 2, + StableID: "2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Priority: 20, + }, + }).View(), + }).View(), + wantValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pickWeighted(tt.candidates) + if !reflect.DeepEqual(got, tt.wantValue) { + t.Errorf("got value %v want %v", got, tt.wantValue) + if tt.wantValid != got.Valid() { + t.Errorf("got invalid candidate expected valid") + } + if tt.wantValid { + if !reflect.DeepEqual(got, tt.wantValue) { + t.Errorf("got value %v want %v", got, tt.wantValue) + } + } + } + }) + } +} + +func TestSuggestExitNodeLongLatDistance(t *testing.T) { + tests := []struct { + name string + firstDistance []float64 + secondDistance []float64 + distanceValue float64 + }{ + { + name: "zero values", + firstDistance: []float64{0, 0}, + secondDistance: []float64{0, 0}, + distanceValue: 0, + }, + { + name: "valid values", + firstDistance: []float64{40.7128, -73.935242}, + secondDistance: []float64{37.7749, -122.4194}, + distanceValue: 4132000, + }, + { + name: "valid values, locations in north and south of equator", + firstDistance: []float64{40.7128, -73.935242}, + secondDistance: []float64{-33.867778, 151.2093}, + distanceValue: 15994658, + }, + } + // Check if the absolute value of the difference between the calculated distance value + // and the hardcoded distance value is greater than 100km. + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := longLatDistance(tt.firstDistance, tt.secondDistance) + if math.Abs(got-tt.distanceValue) > 100000 { + t.Errorf("got value %v distance value %v absolute value difference %v", got, tt.distanceValue, math.Abs(got-tt.distanceValue)) + } + }) + } +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index a10668a188219..d61a3f51895f5 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -140,6 +140,7 @@ var handler = map[string]localAPIHandler{ "update/check": (*Handler).serveUpdateCheck, "update/install": (*Handler).serveUpdateInstall, "update/progress": (*Handler).serveUpdateProgress, + "suggest-exit-node": (*Handler).serveSuggestExitNode, } var ( @@ -2859,3 +2860,25 @@ var ( // User-visible LocalAPI endpoints. metricFilePutCalls = clientmetric.NewCounter("localapi_file_put") ) + +// serveSuggestExitNode serves a POST endpoint for returning a suggested exit node. +func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "access denied", http.StatusForbidden) + return + } + if r.Method != "POST" { + http.Error(w, "want POST", http.StatusBadRequest) + return + } + suggestedExitNodeID, suggestedExitNodeName, suggestedExitNodeLocation, err := h.b.SuggestExitNode() + if err != nil { + writeErrorJSON(w, err) + return + } + var res apitype.ExitNodeSuggestionResponse + res.SuggestedExitNodeID = suggestedExitNodeID + res.SuggestedExitNodeName = suggestedExitNodeName + res.SuggestedExitNodeLocation = suggestedExitNodeLocation + json.NewEncoder(w).Encode(res) +} diff --git a/wgengine/magicsock/magicsock.go b/wgengine/magicsock/magicsock.go index a82e710b21b14..fc7ca40c474fa 100644 --- a/wgengine/magicsock/magicsock.go +++ b/wgengine/magicsock/magicsock.go @@ -3027,3 +3027,17 @@ func getPeerMTUsProbedMetric(mtu tstun.WireMTU) *clientmetric.Metric { mm, _ := metricRecvDiscoPeerMTUProbesByMTU.LoadOrInit(key, func() *clientmetric.Metric { return clientmetric.NewCounter(key) }) return mm } + +// GetLastNetcheckReport returns the last netcheck report, running a new one if a recent one does not exist. +func (c *Conn) GetLastNetcheckReport(ctx context.Context) *netcheck.Report { + lastReport := c.lastNetCheckReport.Load() + if lastReport == nil { + nr, err := c.updateNetInfo(ctx) + if err != nil { + c.logf("magicsock.Conn.GetLastNetcheckReport: updateNetInfo: %v", err) + return nil + } + return nr + } + return lastReport +}