Skip to content

Commit

Permalink
cmd/tailscale, ipn/ipnlocal: add suggest exit node CLI option
Browse files Browse the repository at this point in the history
Updates tailscale/corp#17516

Signed-off-by: Claire Wang <claire@tailscale.com>
  • Loading branch information
clairew committed Apr 2, 2024
1 parent 27038ee commit 8dbc130
Show file tree
Hide file tree
Showing 8 changed files with 817 additions and 1 deletion.
6 changes: 6 additions & 0 deletions client/tailscale/apitype/apitype.go
Expand Up @@ -49,3 +49,9 @@ type ReloadConfigResponse struct {
Reloaded bool // whether the config was reloaded
Err string // any error message
}

type ExitNodeSuggestionResponse struct {
SuggestedExitNodeID tailcfg.StableNodeID
SuggestedExitNodeName string
SuggestedExitNodeLocation tailcfg.Location
}
13 changes: 13 additions & 0 deletions client/tailscale/localclient.go
Expand Up @@ -1505,3 +1505,16 @@ func (w *IPNBusWatcher) Next() (ipn.Notify, error) {
}
return n, nil
}

// SuggestExitNode returns the tailcfg.StableNodeID of a suggested exit node to connect to.
func (lc *LocalClient) SuggestExitNode(ctx context.Context) (tailcfg.StableNodeID, string, tailcfg.Location, error) {
body, err := lc.send(ctx, "POST", "/localapi/v0/suggest-exit-node", 200, nil)
if err != nil {
return "", "", tailcfg.Location{}, fmt.Errorf("error %w: %s", err, body)
}
res, err := decodeJSON[apitype.ExitNodeSuggestionResponse](body)
if err != nil {
return "", "", tailcfg.Location{}, err
}
return res.SuggestedExitNodeID, res.SuggestedExitNodeName, res.SuggestedExitNodeLocation, nil
}
25 changes: 25 additions & 0 deletions cmd/tailscale/cli/exitnode.go
Expand Up @@ -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")
Expand Down Expand Up @@ -98,7 +108,22 @@ 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, suggestedNodeName, _, err := localClient.SuggestExitNode(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, name: %v To set as exit node run `tailscale set --exit-node=<nodeid>`.\n", suggestedNodeID, suggestedNodeName)
}
return nil
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/tailscaled/depaware.txt
Expand Up @@ -287,7 +287,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+
Expand Down
155 changes: 155 additions & 0 deletions ipn/ipnlocal/local.go
Expand Up @@ -58,6 +58,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"
Expand Down Expand Up @@ -6195,3 +6196,157 @@ func mayDeref[T any](p *T) (v T) {
}
return *p
}

var errNoSuggestion = errors.New("no suggestion available")
var errUnableToPick = errors.New("unable to pick candidate")

// SuggestExitNode returns a tailcfg.StableNodeID 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() (tailcfg.StableNodeID, string, tailcfg.Location, error) {
b.mu.Lock()
lastReport := b.MagicConn().GetLastNetcheckReport(b.ctx)
netMap := b.netMap
b.mu.Unlock()
return suggestExitNode(lastReport, netMap)
}

func suggestExitNode(lastReport *netcheck.Report, netMap *netmap.NetworkMap) (tailcfg.StableNodeID, string, tailcfg.Location, error) {
var preferredExitNodeID tailcfg.StableNodeID
var preferredExitNodeName string
var preferredExitNodeLocation tailcfg.Location
if lastReport.PreferredDERP == 0 {
return preferredExitNodeID, "", tailcfg.Location{}, errUnableToPick
}
var candidates []tailcfg.NodeView
peers := netMap.Peers
for _, peer := range peers {
if peer.CapMap().Has(tailcfg.NodeAttrSuggestExitNode) {
if online := peer.Online(); online != nil && !*online && (!peer.CapMap().Has(tailcfg.NodeAttrSuggestExitNode)) {
continue
}
if tsaddr.ContainsExitRoutes(peer.AllowedIPs()) {
candidates = append(candidates, peer)
}
}
}
if len(candidates) == 1 {
if candidates[0].Valid() {
hi := candidates[0].Hostinfo()
if hi.Valid() && hi.Location().View().Valid() {
preferredExitNodeLocation = *hi.Location()
}
return candidates[0].StableID(), candidates[0].Name(), preferredExitNodeLocation, nil
} else {
return preferredExitNodeID, "", tailcfg.Location{}, errUnableToPick
}
}
if len(candidates) == 0 {
return preferredExitNodeID, "", tailcfg.Location{}, errNoSuggestion
}

candidateLatencyMap := make(map[int][]tailcfg.NodeView, len(netMap.DERPMap.Regions))
candidateDistanceMap := make(map[float64][]tailcfg.NodeView, len(candidates))
preferredDerp := netMap.DERPMap.Regions[lastReport.PreferredDERP]
sortedRegions := make([]int, 0, len(netMap.DERPMap.Regions))
distances := make([]float64, 0, len(candidates))
if preferredDerp == nil {
return preferredExitNodeID, "", tailcfg.Location{}, errUnableToPick
}
derpHomeCoordinates := []float64{preferredDerp.Latitude, preferredDerp.Longitude}
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(sortedRegions, regionID) {
sortedRegions = append(sortedRegions, regionID)
}
candidateLatencyMap[regionID] = append(candidateLatencyMap[regionID], candidate)
}
} else {
if candidate.Hostinfo().Location().View().Valid() {
candidateCoordinates := []float64{candidate.Hostinfo().Location().Latitude, candidate.Hostinfo().Location().Longitude}
distance := longLatDistance(derpHomeCoordinates, candidateCoordinates)
candidateDistanceMap[distance] = append(candidateDistanceMap[distance], candidate)
distances = append(distances, distance)
}
}
}
}
sortedRegions = sortRegions(sortedRegions, lastReport)
if len(sortedRegions) > 0 && candidateLatencyMap[sortedRegions[0]] != nil && candidateLatencyMap[sortedRegions[0]][0].Valid() {
preferredExitNodeID = candidateLatencyMap[sortedRegions[0]][0].StableID()
preferredExitNodeName = candidateLatencyMap[sortedRegions[0]][0].Name()
hi := candidateLatencyMap[sortedRegions[0]][0].Hostinfo()
if hi.Valid() && hi.Location().View().Valid() {
preferredExitNodeLocation = *hi.Location()
}
} else {
slices.Sort(distances)
if len(distances) > 0 {
if len(candidateDistanceMap[distances[0]]) == 1 && candidateDistanceMap[distances[0]][0].Valid() {
preferredExitNodeID = candidateDistanceMap[distances[0]][0].StableID()
preferredExitNodeName = candidateDistanceMap[distances[0]][0].Name()
preferredExitNodeLocation = *candidateDistanceMap[distances[0]][0].Hostinfo().Location()
} else {
chosen := pickWeighted(candidateDistanceMap[distances[0]])
if chosen.Valid() {
preferredExitNodeID = chosen.StableID()
preferredExitNodeName = chosen.Name()
preferredExitNodeLocation = *chosen.Hostinfo().Location()
}
}
}
}
if preferredExitNodeID.IsZero() {
return preferredExitNodeID, "", preferredExitNodeLocation, errNoSuggestion
}
return preferredExitNodeID, preferredExitNodeName, preferredExitNodeLocation, nil
}

func pickWeighted(candidates []tailcfg.NodeView) tailcfg.NodeView {
maxWeight := 0
var chosenCandidate tailcfg.NodeView
for _, candidate := range candidates {
if candidate.Hostinfo().Location().View().Valid() && candidate.Hostinfo().Location().Priority > maxWeight {
maxWeight = candidate.Hostinfo().Location().Priority
chosenCandidate = candidate
}
}
return chosenCandidate
}

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

// longLatDistance returns an estimated distance given two lists of float64 values that represent geographic coordinates.
// The first index is latitude and the second index is longitude.
// Value is returned in meters. We tolerate up to 100km difference in accuracy.
func longLatDistance(firstDistance []float64, secondDistance []float64) float64 {
diffLat := (firstDistance[0] - secondDistance[0]) * math.Pi / 180
diffLong := (firstDistance[1] - secondDistance[1]) * math.Pi / 180
latRadians1 := firstDistance[0] * math.Pi / 180
latRadians2 := secondDistance[0] * math.Pi / 180
a := math.Pow(math.Sin(diffLat/2), 2) + math.Cos(latRadians1)*math.Cos(latRadians2)*math.Pow(math.Sin(diffLong/2), 2)
earthRadius := float64(6371000) // earth radius is 6371000 meters
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadius * c
}

0 comments on commit 8dbc130

Please sign in to comment.