From 7101c9be4d3ed1ec735978d6a3b26b3902e25abf Mon Sep 17 00:00:00 2001 From: Claire Wang Date: Tue, 2 Apr 2024 13:04:57 -0400 Subject: [PATCH] cmd/tailscale, ipn/ipnlocal: add suggest exit node CLI option Updates tailscale/corp#17516 Signed-off-by: Claire Wang --- client/tailscale/apitype/apitype.go | 8 + client/tailscale/localclient.go | 13 + cmd/tailscale/cli/exitnode.go | 40 +- cmd/tailscaled/depaware.txt | 2 +- ipn/ipnlocal/local.go | 161 +++++++ ipn/ipnlocal/local_test.go | 664 ++++++++++++++++++++++++++++ ipn/localapi/localapi.go | 15 + wgengine/magicsock/magicsock.go | 14 + 8 files changed, 915 insertions(+), 2 deletions(-) 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..5c809c6831dc3 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) (apitype.ExitNodeSuggestionResponse, error) { + body, err := lc.send(ctx, "GET", "/localapi/v0/suggest-exit-node", 200, nil) + if err != nil { + return apitype.ExitNodeSuggestionResponse{ID: "", Name: "", Location: tailcfg.Location{}}, fmt.Errorf("error %w: %s", err, body) + } + res, err := decodeJSON[apitype.ExitNodeSuggestionResponse](body) + if err != nil { + return apitype.ExitNodeSuggestionResponse{ID: "", Name: "", Location: tailcfg.Location{}}, err + } + return apitype.ExitNodeSuggestionResponse{ID: res.ID, Name: res.Name, Location: res.Location}, nil +} diff --git a/cmd/tailscale/cli/exitnode.go b/cmd/tailscale/cli/exitnode.go index 77e2453d0761c..2d0a715eb8952 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") @@ -97,11 +107,39 @@ 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 is available.") + return nil + } else { + 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 { + var hasSuggestion bool + for _, peer := range peers { + if peer.HasCap(tailcfg.NodeAttrSuggestExitNode) { + hasSuggestion = true + } + } + return hasSuggestion +} + // 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..24a9a8cbf7650 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,162 @@ func mayDeref[T any](p *T) (v T) { } return *p } + +// 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() (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(lastReport *netcheck.Report, netMap *netmap.NetworkMap, seed int64) (response apitype.ExitNodeSuggestionResponse, err error) { + var preferredExitNodeID tailcfg.StableNodeID + var preferredExitNodeName string + var preferredExitNodeLocation tailcfg.Location + if lastReport.PreferredDERP == 0 { + return apitype.ExitNodeSuggestionResponse{ID: preferredExitNodeID, Name: "", Location: tailcfg.Location{}}, errors.New("no preferred DERP, try again later") + } + 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) == 0 { + return apitype.ExitNodeSuggestionResponse{ID: preferredExitNodeID, Name: "", Location: tailcfg.Location{}}, nil + } + if len(candidates) == 1 { + hi := candidates[0].Hostinfo() + if hi.Valid() { + if loc := hi.Location(); loc != nil { + preferredExitNodeLocation = *loc + } + } + return apitype.ExitNodeSuggestionResponse{ID: candidates[0].StableID(), Name: candidates[0].Name(), Location: preferredExitNodeLocation}, nil + } + + candidatesByRegion := make(map[int][]tailcfg.NodeView, len(netMap.DERPMap.Regions)) + candidateDistanceMap := make(map[float64][]tailcfg.NodeView, len(candidates)) + preferredDerp := netMap.DERPMap.Regions[lastReport.PreferredDERP] + regions := make([]int, 0, len(netMap.DERPMap.Regions)) + var minDistance float64 = math.MaxFloat64 + if preferredDerp == nil { + return apitype.ExitNodeSuggestionResponse{ID: preferredExitNodeID, Name: "", Location: tailcfg.Location{}}, errors.New("no preferred DERP, try again later") + } + 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(regions, regionID) { + regions = append(regions, regionID) + } + candidatesByRegion[regionID] = append(candidatesByRegion[regionID], candidate) + } + } else { + if candidate.Hostinfo().Location().View().Valid() { + distance := longLatDistance(preferredDerp.Latitude, preferredDerp.Longitude, candidate.Hostinfo().Location().Latitude, candidate.Hostinfo().Location().Longitude) + if distance < minDistance { + minDistance = distance + } + candidateDistanceMap[distance] = append(candidateDistanceMap[distance], candidate) + } + } + } + } + minRegion := minLatencyDERPRegion(regions, lastReport) + if candidatesByRegion[minRegion] != nil { + chosen := pickDERPNode(candidatesByRegion[minRegion], seed) + preferredExitNodeID = chosen.StableID() + preferredExitNodeName = chosen.Name() + hi := chosen.Hostinfo() + if hi.Valid() && hi.Location().View().Valid() { + preferredExitNodeLocation = *hi.Location() + } + } else { + const minDistanceEpsilon = 100000 // pick mullvad nodes that are at most 100km away from the minimum distance found. + var pickFrom []tailcfg.NodeView + for candidateDistance, candidate := range candidateDistanceMap { + if candidateDistance <= minDistance+minDistanceEpsilon { + pickFrom = append(pickFrom, candidate...) + } + } + chosen := pickWeighted(pickFrom) + if chosen.Valid() { + preferredExitNodeID = chosen.StableID() + preferredExitNodeName = chosen.Name() + preferredExitNodeLocation = *chosen.Hostinfo().Location() + } + } + if preferredExitNodeID.IsZero() { + return apitype.ExitNodeSuggestionResponse{ID: preferredExitNodeID, Name: "", Location: preferredExitNodeLocation}, nil + } + return apitype.ExitNodeSuggestionResponse{ID: preferredExitNodeID, Name: preferredExitNodeName, Location: preferredExitNodeLocation}, nil +} + +// pickWeighted chooses the node with highest priority given a list of mullvad nodes. +func pickWeighted(candidates []tailcfg.NodeView) tailcfg.NodeView { + maxWeight := 0 + var chosenCandidate tailcfg.NodeView + for _, candidate := range candidates { + if loc := candidate.Hostinfo().Location(); loc != nil && loc.Priority > maxWeight { + maxWeight = loc.Priority + chosenCandidate = candidate + } + } + return chosenCandidate +} + +// 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, lastReport *netcheck.Report) int { + minLatency := time.Duration(math.MaxInt64) + var minRegion int + for _, region := range regions { + latency, ok := lastReport.RegionLatency[region] + if !ok { + continue + } + if latency < minLatency { + minLatency = latency + minRegion = region + } + } + return minRegion +} + +// longLatDistance returns an estimated distance given the geographic coordinates of two locations. +// The coordinates are separated into four separate float64 values. +// Value is returned in meters. We tolerate up to 100km difference in accuracy. +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..bf7cb503ca32e 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,665 @@ 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: "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: 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: errors.New("no preferred DERP, try again later"), + }, + { + 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) || !reflect.DeepEqual(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: 0, + 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..e9b6226e5a63b 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,17 @@ 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, "want GET", http.StatusBadRequest) + return + } + res, err := h.b.SuggestExitNode() + if err != nil { + writeErrorJSON(w, err) + return + } + 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 +}