cmd/tailscale/cli: suggest exit node

Updates tailscale/corp#17516

Signed-off-by: Claire Wang <claire@tailscale.com>
This commit is contained in:
Claire Wang 2024-02-13 09:11:02 -05:00
parent 3aca29e00e
commit 343b29e971
9 changed files with 503 additions and 2 deletions

View File

@ -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.
//

View File

@ -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 {

View File

@ -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+

View File

@ -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(),
}
}

View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -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)
}

View File

@ -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.

View File

@ -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
}