summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorClaire Wang <claire@tailscale.com>2024-02-13 09:11:02 -0500
committerClaire Wang <claire@tailscale.com>2024-02-20 16:25:50 -0500
commit343b29e9715f4d85ed5eaa583d692a8467ee9ad7 (patch)
treeb0be192beda4c5638e5bb6ff38b55e112c1262b1
parent3aca29e00e3c6ef71841de8fb66759c3812aeec3 (diff)
downloadtailscale-clairew/suggest-non-mullvad-exit-node.tar.xz
tailscale-clairew/suggest-non-mullvad-exit-node.zip
cmd/tailscale/cli: suggest exit nodeclairew/suggest-non-mullvad-exit-node
Updates tailscale/corp#17516 Signed-off-by: Claire Wang <claire@tailscale.com>
-rw-r--r--client/tailscale/localclient.go13
-rw-r--r--cmd/tailscale/cli/exitnode.go27
-rw-r--r--cmd/tailscaled/depaware.txt2
-rw-r--r--control/controlknobs/controlknobs.go6
-rw-r--r--ipn/ipnlocal/local.go98
-rw-r--r--ipn/ipnlocal/local_test.go323
-rw-r--r--ipn/localapi/localapi.go19
-rw-r--r--tailcfg/tailcfg.go3
-rw-r--r--wgengine/magicsock/magicsock.go14
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=<nodeid>`.\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
+}