diff --git a/client/tailscale/apitype/apitype.go b/client/tailscale/apitype/apitype.go index 6458f510b3a13..5fe6ed554f148 100644 --- a/client/tailscale/apitype/apitype.go +++ b/client/tailscale/apitype/apitype.go @@ -49,3 +49,11 @@ type ReloadConfigResponse struct { Reloaded bool // whether the config was reloaded Err string // any error message } + +// ExitNodeSuggestionResponse is the response to a LocalAPI suggest-exit-node GET request. +// It returns the StableNodeID, name, and location of a suggested exit node for the client making the request. +type ExitNodeSuggestionResponse struct { + ID tailcfg.StableNodeID + Name string + Location tailcfg.Location +} diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 2bc4fa16d1126..1a05b42f3e625 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -1505,3 +1505,12 @@ func (w *IPNBusWatcher) Next() (ipn.Notify, error) { } return n, nil } + +// SuggestExitNode requests an exit node suggestion and returns the exit node's details. +func (lc *LocalClient) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) { + body, err := lc.get200(ctx, "/localapi/v0/suggest-exit-node") + if err != nil { + return apitype.ExitNodeSuggestionResponse{}, err + } + return decodeJSON[apitype.ExitNodeSuggestionResponse](body) +} diff --git a/cmd/tailscale/cli/exitnode.go b/cmd/tailscale/cli/exitnode.go index 77e2453d0761c..0d96cf89bfcb5 100644 --- a/cmd/tailscale/cli/exitnode.go +++ b/cmd/tailscale/cli/exitnode.go @@ -37,6 +37,12 @@ var exitNodeCmd = &ffcli.Command{ return fs })(), }, + { + Name: "suggest", + ShortUsage: "exit-node suggest", + ShortHelp: "Picks the best available exit node", + Exec: runExitNodeSuggest, + }, }, Exec: func(context.Context, []string) error { return errors.New("exit-node subcommand required; run 'tailscale exit-node -h' for details") @@ -97,11 +103,37 @@ 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.") + if hasAnyExitNodeSuggestions(peers) { + fmt.Fprintln(w, "# To have Tailscale suggest 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 { + res, err := localClient.SuggestExitNode(ctx) + if err != nil { + return fmt.Errorf("suggest exit node: %w", err) + } + if res.ID == "" { + fmt.Println("No exit node suggestion is available.") + return nil + } + fmt.Printf("Suggested exit node: %v\nTo accept this suggestion, use `tailscale set --exit-node=%v`.\n", res.Name, res.ID) return nil } +func hasAnyExitNodeSuggestions(peers []*ipnstate.PeerStatus) bool { + for _, peer := range peers { + if peer.HasCap(tailcfg.NodeAttrSuggestExitNode) { + return true + } + } + return false +} + // peerStatus returns a string representing the current state of // a peer. If there is no notable state, a - is returned. func peerStatus(peer *ipnstate.PeerStatus) string { 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 f4865d9f4c157..1fe9be2a379ee 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -14,6 +14,7 @@ import ( "log" "maps" "math" + "math/rand" "net" "net/http" "net/netip" @@ -59,6 +60,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 +6205,177 @@ func mayDeref[T any](p *T) (v T) { } return *p } + +var ErrNoPreferredDERP = errors.New("no preferred DERP, try again later") + +// SuggestExitNode 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. +// +// 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 (b *LocalBackend) SuggestExitNode() (response apitype.ExitNodeSuggestionResponse, err error) { + b.mu.Lock() + lastReport := b.MagicConn().GetLastNetcheckReport(b.ctx) + netMap := b.netMap + b.mu.Unlock() + seed := time.Now().UnixNano() + return suggestExitNode(lastReport, netMap, seed) +} + +func suggestExitNode(report *netcheck.Report, netMap *netmap.NetworkMap, seed int64) (res apitype.ExitNodeSuggestionResponse, err error) { + if report.PreferredDERP == 0 { + return res, ErrNoPreferredDERP + } + candidates := make([]tailcfg.NodeView, 0, len(netMap.Peers)) + for _, peer := range netMap.Peers { + if peer.CapMap().Has(tailcfg.NodeAttrSuggestExitNode) && tsaddr.ContainsExitRoutes(peer.AllowedIPs()) { + candidates = append(candidates, peer) + } + } + if len(candidates) == 0 { + return res, nil + } + if len(candidates) == 1 { + peer := candidates[0] + if hi := peer.Hostinfo(); hi.Valid() { + if loc := hi.Location(); loc != nil { + res.Location = *loc + } + } + res.ID = peer.StableID() + res.Name = peer.Name() + return res, nil + } + + candidatesByRegion := make(map[int][]tailcfg.NodeView, len(netMap.DERPMap.Regions)) + candidateDistanceMap := make(map[float64][]tailcfg.NodeView, len(candidates)) + var preferredDERP *tailcfg.DERPRegion + if report.PreferredDERP > 0 { + preferredDERP = netMap.DERPMap.Regions[report.PreferredDERP] + } + if preferredDERP == nil { + return res, ErrNoPreferredDERP + } + var minDistance float64 = math.MaxFloat64 + for _, c := range candidates { + if !c.Valid() { + continue + } + if c.DERP() != "" { + ipp, err := netip.ParseAddrPort(c.DERP()) + if err != nil { + continue + } + if ipp.Addr() != tailcfg.DerpMagicIPAddr { + continue + } + regionID := int(ipp.Port()) + candidatesByRegion[regionID] = append(candidatesByRegion[regionID], c) + continue + } + // This candidate does not have a DERP home. + // Since a candidate exists that does have a DERP home, skip this candidate. We never select + // a candidate without a DERP home if there is a candidate available with a DERP home. + // Use geographic distance from our DERP home to estimate how good this candidate is. + hi := c.Hostinfo() + if !hi.Valid() { + continue + } + loc := hi.Location() + if loc == nil { + continue + } + distance := longLatDistance(preferredDERP.Latitude, preferredDERP.Longitude, c.Hostinfo().Location().Latitude, c.Hostinfo().Location().Longitude) + if distance < minDistance { + minDistance = distance + } + candidateDistanceMap[distance] = append(candidateDistanceMap[distance], c) + } + // First, try to select an exit node that has the closest DERP home, based on lastReport's DERP latency. + if len(candidatesByRegion) > 0 { + minRegion := minLatencyDERPRegion(xmaps.Keys(candidatesByRegion), report) + regionCandidates, ok := candidatesByRegion[minRegion] + if !ok { + return res, errors.New("no candidates in expected region: this is a bug") + } + chosen := pickDERPNode(regionCandidates, seed) + res.ID = chosen.StableID() + res.Name = chosen.Name() + if hi := chosen.Hostinfo(); hi.Valid() { + if loc := hi.Location(); loc != nil { + res.Location = *loc + } + } + return res, nil + } + // None of the candidates have a DERP home, so proceed to select based on geographical distance from our preferred DERP region. + const minDistanceEpsilon = 100000 // consider peers at most 100km further away than the minimum distance found + var pickFrom []tailcfg.NodeView + for dist, c := range candidateDistanceMap { + if dist <= minDistance+minDistanceEpsilon { + pickFrom = append(pickFrom, c...) + } + } + chosen := pickWeighted(pickFrom) + if !chosen.Valid() { + return res, errors.New("chosen candidate invalid: this is a bug") + } + res.ID = chosen.StableID() + res.Name = chosen.Name() + if hi := chosen.Hostinfo(); hi.Valid() { + if loc := hi.Location(); loc != nil { + res.Location = *loc + } + } + return res, nil +} + +// pickWeighted chooses the node with highest priority given a list of mullvad nodes. +func pickWeighted(candidates []tailcfg.NodeView) tailcfg.NodeView { + maxWeight := 0 + var best tailcfg.NodeView + for _, c := range candidates { + hi := c.Hostinfo() + if !hi.Valid() { + continue + } + loc := hi.Location() + if loc == nil || loc.Priority <= maxWeight { + continue + } + maxWeight = loc.Priority + best = c + } + return best +} + +// pickDERPNode chooses a node randomly given a list of nodes and a seed. +func pickDERPNode(nodes []tailcfg.NodeView, seed int64) tailcfg.NodeView { + r := rand.New(rand.NewSource(seed)) + return nodes[r.Intn(len(nodes))] +} + +// 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 { + return slices.MinFunc(regions, func(i, j int) int { + return cmp.Compare(report.RegionLatency[i], report.RegionLatency[j]) + }) +} + +// longLatDistance returns an estimated distance given the geographic coordinates of two locations, in degrees. +// The coordinates are separated into four separate float64 values. +// Value is returned in meters. +func longLatDistance(fromLat, fromLong, toLat, toLong float64) float64 { + const toRadians = math.Pi / 180 + diffLat := (fromLat - toLat) * toRadians + diffLong := (fromLong - toLong) * toRadians + lat1 := fromLat * toRadians + lat2 := toLat * toRadians + a := math.Pow(math.Sin(diffLat/2), 2) + math.Cos(lat1)*math.Cos(lat2)*math.Pow(math.Sin(diffLong/2), 2) + const earthRadiusMeters = float64(6371000) + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return earthRadiusMeters * c +} diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 3876043330263..5c5e4b13b6204 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,719 @@ 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: "3", + wantID: tailcfg.StableNodeID("3"), + }, + { + 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: "3", + wantID: tailcfg.StableNodeID("3"), + }, + { + name: "2 derp based exit nodes, different regions, same latency", + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 10, + 2: 10, + 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, + Priority: 100, + }, + }).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: "San Jose", + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Latitude: 37.3382082, + Longitude: -121.8863286, + Priority: 20, + }, + }).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, + Priority: 100, + }, + wantName: "Dallas", + }, + { + name: "mullvad nodes close to each other, different priorities", + 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, + Priority: 10, + }, + }).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: "Fort Worth", + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Latitude: 37.768799, + Longitude: -97.309341, + Priority: 50, + }, + }).View(), + CapMap: (tailcfg.NodeCapMap)(map[tailcfg.NodeCapability][]tailcfg.RawMessage{ + tailcfg.NodeAttrSuggestExitNode: {}, + }), + }).View(), + }, + }, + wantID: tailcfg.StableNodeID("3"), + wantLocation: tailcfg.Location{ + Latitude: 37.768799, + Longitude: -97.309341, + Priority: 50, + }, + wantName: "Fort Worth", + }, + { + 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, + Priority: 20, + }, + }).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: "San Jose", + Hostinfo: (&tailcfg.Hostinfo{ + Location: &tailcfg.Location{ + Latitude: 37.3382082, + Longitude: -121.8863286, + Priority: 30, + }, + }).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: {}, + }, + }, + }, + }, + { + name: "no preferred derp region", + lastReport: netcheck.Report{ + RegionLatency: map[int]time.Duration{ + 1: 0, + 2: -1, + 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: ErrNoPreferredDERP, + }, + { + 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, + Priority: 30, + }, + }).View(), + }).View(), + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := suggestExitNode(&tt.lastReport, &tt.netMap, 100) + if !reflect.DeepEqual(got.Name, tt.wantName) || !reflect.DeepEqual(got.ID, tt.wantID) || !errors.Is(err, tt.wantError) || !reflect.DeepEqual(got.Location, tt.wantLocation) { + t.Errorf("got name %v id %v location %v error %v want name %v id %v location %v error %v", got.Name, got.ID, got.Location, err, tt.wantName, tt.wantID, tt.wantLocation, tt.wantError) + } + }) + } +} + +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 + fromLat float64 + fromLong float64 + toLat float64 + toLong float64 + distanceValue float64 + }{ + { + name: "zero values", + fromLat: 0, + fromLong: 0, + toLat: 0, + toLong: 0, + distanceValue: 0, + }, + { + name: "valid values", + fromLat: 40.7128, + fromLong: -73.935242, + toLat: 37.7749, + toLong: -122.4194, + distanceValue: 4132000, + }, + { + name: "valid values, locations in north and south of equator", + fromLat: 40.7128, + fromLong: -73.935242, + toLat: -33.867778, + toLong: 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.fromLat, tt.fromLong, tt.toLat, tt.toLong) + 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)) + } + }) + } +} + +func TestMinLatencyDERPregion(t *testing.T) { + tests := []struct { + name string + regions []int + report *netcheck.Report + wantRegion int + }{ + { + name: "regions, no latency values", + regions: []int{1, 2, 3}, + wantRegion: 1, + report: &netcheck.Report{}, + }, + { + name: "regions, different latency values", + 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, + }, + }, + }, + { + name: "regions, same values", + 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, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := minLatencyDERPRegion(tt.regions, tt.report) + if got != tt.wantRegion { + t.Errorf("got region %v want region %v", got, tt.wantRegion) + } + }) + } +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index a387e3156c304..54455a030da4e 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,18 @@ 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 r.Method != "GET" { + http.Error(w, "only GET allowed", http.StatusBadRequest) + return + } + res, err := h.b.SuggestExitNode() + if err != nil { + writeErrorJSON(w, err) + return + } + w.Header().Set("Content-Type", "application/json") + 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 +}