From 343b29e9715f4d85ed5eaa583d692a8467ee9ad7 Mon Sep 17 00:00:00 2001 From: Claire Wang Date: Tue, 13 Feb 2024 09:11:02 -0500 Subject: [PATCH] cmd/tailscale/cli: suggest exit node Updates tailscale/corp#17516 Signed-off-by: Claire Wang --- client/tailscale/localclient.go | 13 ++ cmd/tailscale/cli/exitnode.go | 27 ++- cmd/tailscaled/depaware.txt | 2 +- control/controlknobs/controlknobs.go | 6 + ipn/ipnlocal/local.go | 98 ++++++++ ipn/ipnlocal/local_test.go | 323 +++++++++++++++++++++++++++ ipn/localapi/localapi.go | 19 ++ tailcfg/tailcfg.go | 3 + wgengine/magicsock/magicsock.go | 14 ++ 9 files changed, 503 insertions(+), 2 deletions(-) diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 9efee2780..8acf1aa04 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -1460,6 +1460,19 @@ func (lc *LocalClient) TailFSShareList(ctx context.Context) (map[string]*tailfs. return shares, err } +// SuggestDERPExitNode returns the tailcfg.StableNodeID of a suggested exit node to connect to. +func (lc *LocalClient) SuggestDERPExitNode(ctx context.Context) (tailcfg.StableNodeID, error) { + body, err := lc.send(ctx, "POST", "/localapi/v0/suggest-derp-exit-node", 200, nil) + if err != nil { + return "", fmt.Errorf("error %w: %s", err, body) + } + nodeID, err := decodeJSON[tailcfg.StableNodeID](body) + if err != nil { + return "", err + } + return nodeID, nil +} + // IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus. // It's returned by LocalClient.WatchIPNBus. // diff --git a/cmd/tailscale/cli/exitnode.go b/cmd/tailscale/cli/exitnode.go index 77e2453d0..16be48876 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,26 @@ 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, err := localClient.SuggestDERPExitNode(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. To set as exit node run `tailscale set --exit-node=`.\n", suggestedNodeID) + } + return nil +} + // 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 87adf447c..39526f164 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -286,7 +286,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/control/controlknobs/controlknobs.go b/control/controlknobs/controlknobs.go index 6a36c9261..b8e205eae 100644 --- a/control/controlknobs/controlknobs.go +++ b/control/controlknobs/controlknobs.go @@ -73,6 +73,9 @@ type Knobs struct { // ProbeUDPLifetime is whether the node should probe UDP path lifetime on // the tail end of an active direct connection in magicsock. ProbeUDPLifetime atomic.Bool + + // SuggestExitNode is whether the exit node suggestion feature can be used. + SuggestExitNode atomic.Bool } // UpdateFromNodeAttributes updates k (if non-nil) based on the provided self @@ -100,6 +103,7 @@ func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability, forceNfTables = has(tailcfg.NodeAttrLinuxMustUseNfTables) seamlessKeyRenewal = has(tailcfg.NodeAttrSeamlessKeyRenewal) probeUDPLifetime = has(tailcfg.NodeAttrProbeUDPLifetime) + suggestExitNode = has(tailcfg.NodeAttrSuggestExitNode) ) if has(tailcfg.NodeAttrOneCGNATEnable) { @@ -122,6 +126,7 @@ func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability, k.LinuxForceNfTables.Store(forceNfTables) k.SeamlessKeyRenewal.Store(seamlessKeyRenewal) k.ProbeUDPLifetime.Store(probeUDPLifetime) + k.SuggestExitNode.Store(suggestExitNode) } // AsDebugJSON returns k as something that can be marshalled with json.Marshal @@ -145,5 +150,6 @@ func (k *Knobs) AsDebugJSON() map[string]any { "LinuxForceNfTables": k.LinuxForceNfTables.Load(), "SeamlessKeyRenewal": k.SeamlessKeyRenewal.Load(), "ProbeUDPLifetime": k.ProbeUDPLifetime.Load(), + "SuggestExitNode": k.SuggestExitNode.Load(), } } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index b8aa769a1..cd10c9323 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -13,6 +13,8 @@ import ( "io" "log" "maps" + "math" + "math/rand" "net" "net/http" "net/netip" @@ -57,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" @@ -5990,3 +5993,98 @@ func mayDeref[T any](p *T) (v T) { } return *p } + +func (b *LocalBackend) suggestExitNodeEnabled() bool { + return b.ControlKnobs().SuggestExitNode.Load() +} + +var errNoExitNodes = errors.New("no exit nodes available") +var errUnableToPick = errors.New("unable to pick candidate") + +// SuggestDERPExitNode returns a tailcfg.StableNodeID of a suggested exit node given the local backend's netmap and last report. +func (b *LocalBackend) SuggestDERPExitNode() (tailcfg.StableNodeID, error) { + b.mu.Lock() + lastReport := b.MagicConn().GetLastNetcheckReport(b.ctx) + netMap := b.netMap + rng := rand.New(rand.NewSource(rand.Int63())) + b.mu.Unlock() + if b.suggestExitNodeEnabled() { + return suggestDERPExitNode(lastReport, netMap, rng) + } else { + return "", fmt.Errorf("Unable to choose exit node") + } +} + +func suggestDERPExitNode(lastReport *netcheck.Report, netMap *netmap.NetworkMap, rng *rand.Rand) (tailcfg.StableNodeID, error) { + peers := netMap.Peers + var preferredExitNodeID tailcfg.StableNodeID + peerRegionMap := make(map[int][]tailcfg.NodeView, len(netMap.DERPMap.Regions)) + sortedRegions := make([]int, 0, len(netMap.DERPMap.Regions)) + for r := range netMap.DERPMap.Regions { + sortedRegions = append(sortedRegions, r) + } + + sortedRegions = sortRegions(sortedRegions, lastReport) + + for _, peer := range peers { + if online := peer.Online(); online != nil && !*online { + continue + } + if tsaddr.ContainsExitRoutes(peer.AllowedIPs()) { + if peer.DERP() == "" { + continue + } + ipp, err := netip.ParseAddrPort(peer.DERP()) + if err != nil { + continue + } + if ipp.Addr() == tailcfg.DerpMagicIPAddr { + regionID := int(ipp.Port()) + peerRegionMap[regionID] = append(peerRegionMap[regionID], peer) + } + } + } + + for _, r := range sortedRegions { + peers, ok := peerRegionMap[r] + if ok && len(peers) > 0 { + preferredExitNode, err := pick(peers, rng) + if err != nil { + continue + } + preferredExitNodeID = preferredExitNode.StableID() + break + } + } + if preferredExitNodeID.IsZero() { + return preferredExitNodeID, errNoExitNodes + } + return preferredExitNodeID, nil +} + +// pick randomly selects a tailcfg.NodeView given a list of tailcfg.NodeView and rand.Rand. +func pick(candidates []tailcfg.NodeView, rng *rand.Rand) (tailcfg.NodeView, error) { + if len(candidates) < 1 { + return (&tailcfg.Node{}).View(), errUnableToPick + } + if len(candidates) == 1 { + return candidates[0], nil + } + return candidates[rng.Intn(len(candidates))], nil +} + +// 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 +} diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index a3cb7e213..94b86d960 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "math/rand" "net" "net/http" "net/netip" @@ -23,6 +24,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" @@ -2173,3 +2175,324 @@ func TestOnTailnetDefaultAutoUpdate(t *testing.T) { }) } } + +func TestSuggestDerpExitNode(t *testing.T) { + tests := []struct { + name string + lastReport netcheck.Report + netMap netmap.NetworkMap + wantValue tailcfg.StableNodeID + wantError error + }{ + { + name: "2 derp based 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, + }, + }, + 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: {}, + 4: {}, + 5: {}, + 6: {}, + 7: {}, + 8: {}, + }, + }, + Peers: []tailcfg.NodeView{ + (&tailcfg.Node{ + ID: 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, + StableID: "3", + DERP: "127.3.3.40:1", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + }).View(), + }, + }, + wantValue: 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, + }, + }, + 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: {}, + 4: {}, + 5: {}, + 6: {}, + 7: {}, + 8: {}, + }, + }, + Peers: []tailcfg.NodeView{ + (&tailcfg.Node{ + ID: 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, + StableID: "3", + DERP: "127.3.3.40:2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + }).View(), + }, + }, + wantValue: tailcfg.StableNodeID("2"), + }, + { + name: "no derp based exit nodes", + 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: {}, + 4: {}, + 5: {}, + 6: {}, + 7: {}, + 8: {}, + }, + }, + Peers: []tailcfg.NodeView{ + (&tailcfg.Node{ + ID: 2, + StableID: "2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + }).View(), + (&tailcfg.Node{ + ID: 3, + StableID: "3", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + }).View(), + }, + }, + wantError: errNoExitNodes, + }, + { + name: "no exit nodes", + 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: {}, + 4: {}, + 5: {}, + 6: {}, + 7: {}, + 8: {}, + }, + }, + }, + wantError: errNoExitNodes, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := suggestDERPExitNode(&tt.lastReport, &tt.netMap, rand.New(rand.NewSource(10))) + if got != tt.wantValue || err != tt.wantError { + t.Errorf("got value %v error %v want %v error %v", got, err, tt.wantValue, 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 TestSuggestExitNodePick(t *testing.T) { + tests := []struct { + name string + candidates []tailcfg.NodeView + rng *rand.Rand + wantValue tailcfg.NodeView + wantError error + }{ + { + 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"), + }, + }).View(), + (&tailcfg.Node{ + ID: 3, + StableID: "3", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + }).View(), + }, + rng: rand.New(rand.NewSource(2)), + wantValue: (&tailcfg.Node{ + ID: 2, + StableID: "2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + }).View(), + }, + { + name: "<1 candidates", + candidates: []tailcfg.NodeView{}, + rng: rand.New(rand.NewSource(2)), + wantValue: (&tailcfg.Node{}).View(), + wantError: errUnableToPick, + }, + { + 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"), + }, + }).View(), + }, + rng: rand.New(rand.NewSource(2)), + wantValue: (&tailcfg.Node{ + ID: 2, + StableID: "2", + AllowedIPs: []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"), + }, + }).View(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := pick(tt.candidates, tt.rng) + if !reflect.DeepEqual(got, tt.wantValue) || err != tt.wantError { + t.Errorf("got value %v error %v want %v error %v", got, err, tt.wantValue, tt.wantError) + } + }) + } +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index a1b7da46f..25641121c 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -134,6 +134,7 @@ var handler = map[string]localAPIHandler{ "update/check": (*Handler).serveUpdateCheck, "update/install": (*Handler).serveUpdateInstall, "update/progress": (*Handler).serveUpdateProgress, + "suggest-derp-exit-node": (*Handler).serveSuggestDERPExitNode, } var ( @@ -2626,3 +2627,21 @@ var ( // User-visible LocalAPI endpoints. metricFilePutCalls = clientmetric.NewCounter("localapi_file_put") ) + +// serveSuggestDerpExitNode serves a POST endpoint for returning a suggested exit node. +func (h *Handler) serveSuggestDERPExitNode(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, err := h.b.SuggestDERPExitNode() + if err != nil { + writeErrorJSON(w, err) + return + } + json.NewEncoder(w).Encode(suggestedExitNodeID) +} diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 5a26697ff..86b3b9b07 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -2209,6 +2209,9 @@ const ( // NodeAttrsTailFSAccess enables accessing shares via TailFS. NodeAttrsTailFSAccess NodeCapability = "tailfs:access" + + // NodeAttrSuggestExitNode enables using suggest exit node feature. + NodeAttrSuggestExitNode NodeCapability = "suggest-exit-node" ) // SetDNSRequest is a request to add a DNS record. diff --git a/wgengine/magicsock/magicsock.go b/wgengine/magicsock/magicsock.go index e42688602..94fb78c03 100644 --- a/wgengine/magicsock/magicsock.go +++ b/wgengine/magicsock/magicsock.go @@ -3008,3 +3008,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 +}