diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go index 6458f510b3a13..cab6c45775e2a 100644 --- a/client/tailscale/apitype/apitype.go +++ b/client/tailscale/apitype/apitype.go @@ -49,3 +49,8 @@ type ReloadConfigResponse struct { Reloaded bool // whether the config was reloaded Err string // any error message } + +type ExitNodeSuggestionResponse struct { + SuggestedExitNodeID tailcfg.StableNodeID + SuggestExitNodeName string +} diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 644d5d87e9dcb..ce20822067e8b 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 of a suggested exit node to connect to. +func (lc *LocalClient) SuggestExitNode(ctx context.Context) (tailcfg.StableNodeID, string, error) { + body, err := lc.send(ctx, "POST", "/localapi/v0/suggest-exit-node", 200, nil) + if err != nil { + return "", "", fmt.Errorf("error %w: %s", err, body) + } + res, err := decodeJSON[apitype.ExitNodeSuggestionResponse](body) + if err != nil { + return "", "", err + } + return res.SuggestedExitNodeID, res.SuggestExitNodeName, nil +} diff --git a/cmd/tailscale/cli/exitnode.go b/cmd/tailscale/cli/exitnode.go index 77e2453d0761c..1096f7e8be69a 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 f707c67780033..597cce9bc1b92 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -285,7 +285,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 818131c16e38c..6b8fbb3d51447 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -13,6 +13,7 @@ import ( "io" "log" "maps" + "math" "net" "net/http" "net/netip" @@ -57,6 +58,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" @@ -6023,3 +6025,138 @@ func mayDeref[T any](p *T) (v T) { } return *p } + +var errNoExitNodes = errors.New("no exit nodes available") +var errUnableToPick = errors.New("unable to pick candidate") + +// SuggestExitNode returns a tailcfg.StableNodeID 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, 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, error) { + var preferredExitNodeID tailcfg.StableNodeID + var preferredExitNodeName string + if lastReport.PreferredDERP == 0 { + return preferredExitNodeID, "", errUnableToPick + } + var candidates []tailcfg.NodeView + peers := netMap.Peers + for _, peer := range peers { + if online := peer.Online(); online != nil && !*online { + continue + } + if tsaddr.ContainsExitRoutes(peer.AllowedIPs()) { + candidates = append(candidates, peer) + } + } + if len(candidates) == 1 { + return candidates[0].StableID(), candidates[0].Name(), nil + } + if len(candidates) == 0 { + return preferredExitNodeID, "", errNoExitNodes + } + + 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, "", errUnableToPick + } + derpHomeCoordinates := []float64{preferredDerp.Latitude, preferredDerp.Longitude} + for _, candidate := range candidates { + 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() != nil { + 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 { + preferredExitNodeID = candidateLatencyMap[sortedRegions[0]][0].StableID() + preferredExitNodeName = candidateLatencyMap[sortedRegions[0]][0].Name() + } else { + slices.Sort(distances) + if len(distances) > 0 { + if len(candidateDistanceMap[distances[0]]) == 1 { + preferredExitNodeID = candidateDistanceMap[distances[0]][0].StableID() + preferredExitNodeName = candidateDistanceMap[distances[0]][0].Name() + } else { + chosen := pickWeighted(candidateDistanceMap[distances[0]]) + if chosen.Valid() { + preferredExitNodeID = chosen.StableID() + preferredExitNodeName = chosen.Name() + } + } + } + } + if preferredExitNodeID.IsZero() { + return preferredExitNodeID, "", errNoExitNodes + } + return preferredExitNodeID, preferredExitNodeName, nil +} + +func pickWeighted(candidates []tailcfg.NodeView) tailcfg.NodeView { + maxWeight := 0 + var chosenCandidate tailcfg.NodeView + for _, candidate := range candidates { + if candidate.Hostinfo().Location() != nil && 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 d37020ce48064..05fd81d83cbd9 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" @@ -27,6 +28,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/tailfs" @@ -2469,3 +2471,497 @@ func TestTailFSManageShares(t *testing.T) { }) } } + +func TestSuggestExitNode(t *testing.T) { + tests := []struct { + name string + lastReport netcheck.Report + netMap netmap.NetworkMap + wantID tailcfg.StableNodeID + wantName string + 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"), + }, + }).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"), + }, + }).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"), + }, + }).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"), + }, + }).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(), + }).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(), + }).View(), + }, + }, + wantID: tailcfg.StableNodeID("2"), + 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(), + }).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(), + }).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"), + }, + }).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: errNoExitNodes, + }, + { + 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, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID, gotName, err := suggestExitNode(&tt.lastReport, &tt.netMap) + if gotName != tt.wantName || gotID != tt.wantID || err != tt.wantError { + t.Errorf("got name %v id %v error %v want name %v id %v error %v", gotName, gotID, err, tt.wantName, tt.wantID, 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 41bfe2bd0ff73..4933a7d6e2ab2 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -135,6 +135,7 @@ var handler = map[string]localAPIHandler{ "update/check": (*Handler).serveUpdateCheck, "update/install": (*Handler).serveUpdateInstall, "update/progress": (*Handler).serveUpdateProgress, + "suggest-exit-node": (*Handler).serveSuggestExitNode, } var ( @@ -2679,3 +2680,24 @@ 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, err := h.b.SuggestExitNode() + if err != nil { + writeErrorJSON(w, err) + return + } + var res apitype.ExitNodeSuggestionResponse + res.SuggestedExitNodeID = suggestedExitNodeID + res.SuggestExitNodeName = suggestedExitNodeName + json.NewEncoder(w).Encode(res) +} diff --git a/wgengine/magicsock/magicsock.go b/wgengine/magicsock/magicsock.go index 39a8ce528f087..2dd6469c0a108 100644 --- a/wgengine/magicsock/magicsock.go +++ b/wgengine/magicsock/magicsock.go @@ -3010,3 +3010,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. +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 +}