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 10, 2024
1 parent da4e92b commit fc74857
Show file tree
Hide file tree
Showing 8 changed files with 1,025 additions and 2 deletions.
8 changes: 8 additions & 0 deletions client/tailscale/apitype/apitype.go
Expand Up @@ -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
}
9 changes: 9 additions & 0 deletions client/tailscale/localclient.go
Expand Up @@ -1514,3 +1514,12 @@ func (w *IPNBusWatcher) Next() (ipn.Notify, error) {
}
return n, nil
}

// SuggestExitNode requests an exit node suggestion and returns the exit node's details.
func (lc *LocalClient) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
body, err := lc.get200(ctx, "/localapi/v0/suggest-exit-node")
if err != nil {
return apitype.ExitNodeSuggestionResponse{}, err
}
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
}
34 changes: 33 additions & 1 deletion cmd/tailscale/cli/exitnode.go
Expand Up @@ -40,6 +40,12 @@ func exitNodeCmd() *ffcli.Command {
fs.StringVar(&exitNodeArgs.filter, "filter", "", "filter exit nodes by country")
return fs
})(),
},
{
Name: "suggest",
ShortUsage: "tailscale exit-node suggest",
ShortHelp: "Picks the best available exit node",
Exec: runExitNodeSuggest,
}},
(func() []*ffcli.Command {
if !envknob.UseWIPCode() {
Expand Down Expand Up @@ -134,11 +140,37 @@ 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 suggestion is available.")
return nil
}
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 {
for _, peer := range peers {
if peer.HasCap(tailcfg.NodeAttrSuggestExitNode) {
return true
}
}
return false
}

// 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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/tailscaled/depaware.txt
Expand Up @@ -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+
Expand Down
212 changes: 212 additions & 0 deletions ipn/ipnlocal/local.go
Expand Up @@ -14,6 +14,7 @@ import (
"log"
"maps"
"math"
"math/rand"
"net"
"net/http"
"net/netip"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -6323,3 +6325,213 @@ func mayDeref[T any](p *T) (v T) {
}
return *p
}

var ErrNoPreferredDERP = errors.New("no preferred DERP, try again later")

// SuggestExitNode computes a suggestion based on the current netmap and last netcheck report. If
// there are multiple equally good options, one is selected at random, so the result is not stable. To be
// eligible for consideration, the peer must have NodeAttrSuggestExitNode in its CapMap.
//
// Currently, peers with a DERP home are preferred over those without (typically this means Mullvad).
// Peers are selected based on having a DERP home that is the lowest latency to this device. For peers
// without a DERP home, we look for geographic proximity to this device's DERP home.
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()
r := rand.New(rand.NewSource(seed))
return suggestExitNode(lastReport, netMap, r)
}

func suggestExitNode(report *netcheck.Report, netMap *netmap.NetworkMap, r *rand.Rand) (res apitype.ExitNodeSuggestionResponse, err error) {
if report.PreferredDERP == 0 {
return res, ErrNoPreferredDERP
}
candidates := make([]tailcfg.NodeView, 0, len(netMap.Peers))
for _, peer := range netMap.Peers {
if peer.CapMap().Has(tailcfg.NodeAttrSuggestExitNode) && tsaddr.ContainsExitRoutes(peer.AllowedIPs()) {
candidates = append(candidates, peer)
}
}
if len(candidates) == 0 {
return res, nil
}
if len(candidates) == 1 {
peer := candidates[0]
if hi := peer.Hostinfo(); hi.Valid() {
if loc := hi.Location(); loc != nil {
res.Location = *loc
}
}
res.ID = peer.StableID()
res.Name = peer.Name()
return res, nil
}

candidatesByRegion := make(map[int][]tailcfg.NodeView, len(netMap.DERPMap.Regions))
var preferredDERP *tailcfg.DERPRegion = netMap.DERPMap.Regions[report.PreferredDERP]
var minDistance float64 = math.MaxFloat64
type nodeDistance struct {
nv tailcfg.NodeView
distance float64 // in meters, approximately
}
distances := make([]nodeDistance, 0, len(candidates))
for _, c := range candidates {
if !c.Valid() {
continue
}
if c.DERP() != "" {
ipp, err := netip.ParseAddrPort(c.DERP())
if err != nil {
continue
}
if ipp.Addr() != tailcfg.DerpMagicIPAddr {
continue
}
regionID := int(ipp.Port())
candidatesByRegion[regionID] = append(candidatesByRegion[regionID], c)
continue
}
if len(candidatesByRegion) > 0 {
// Since a candidate exists that does have a DERP home, skip this candidate. We never select
// a candidate without a DERP home if there is a candidate available with a DERP home.
continue
}
// This candidate does not have a DERP home.
// Use geographic distance from our DERP home to estimate how good this candidate is.
hi := c.Hostinfo()
if !hi.Valid() {
continue
}
loc := hi.Location()
if loc == nil {
continue
}
distance := longLatDistance(preferredDERP.Latitude, preferredDERP.Longitude, loc.Latitude, loc.Longitude)
if distance < minDistance {
minDistance = distance
}
distances = append(distances, nodeDistance{nv: c, distance: distance})
}
// First, try to select an exit node that has the closest DERP home, based on lastReport's DERP latency.
// If there are no latency values, it returns an arbitrary region
if len(candidatesByRegion) > 0 {
minRegion := minLatencyDERPRegion(xmaps.Keys(candidatesByRegion), report)
if minRegion == 0 {
minRegion = pickDERPRegion(xmaps.Keys(candidatesByRegion), r)
}
regionCandidates, ok := candidatesByRegion[minRegion]
if !ok {
return res, errors.New("no candidates in expected region: this is a bug")
}
chosen := pickDERPNode(regionCandidates, r)
res.ID = chosen.StableID()
res.Name = chosen.Name()
if hi := chosen.Hostinfo(); hi.Valid() {
if loc := hi.Location(); loc != nil {
res.Location = *loc
}
}
return res, nil
}

// None of the candidates have a DERP home, so proceed to select based on geographical distance from our preferred DERP region.
// allowanceMeters is the extra distance that will be permitted when considering peers. By this point, there
// are multiple approximations taking place (DERP location standing in for this device's location, the peer's
// location may only be city granularity, the distance algorithm assumes a spherical planet, etc.) so it is
// reasonable to consider peers that are similar distances. Those peers are good enough to be within
// measurement error. 100km corresponds to approximately 1ms of additional round trip light
// propagation delay in a fiber optic cable and seems like a reasonable heuristic. It may be adjusted in
// future.
const allowanceMeters = 100000
pickFrom := make([]tailcfg.NodeView, 0, len(distances))
for _, candidate := range distances {
if candidate.nv.Valid() && candidate.distance <= minDistance+allowanceMeters {
pickFrom = append(pickFrom, candidate.nv)
}
}
chosen := pickWeighted(pickFrom)
if !chosen.Valid() {
return res, errors.New("chosen candidate invalid: this is a bug")
}
res.ID = chosen.StableID()
res.Name = chosen.Name()
if hi := chosen.Hostinfo(); hi.Valid() {
if loc := hi.Location(); loc != nil {
res.Location = *loc
}
}
return res, nil
}

// pickWeighted chooses the node with highest priority given a list of mullvad nodes.
func pickWeighted(candidates []tailcfg.NodeView) tailcfg.NodeView {
maxWeight := 0
var best tailcfg.NodeView
for _, c := range candidates {
hi := c.Hostinfo()
if !hi.Valid() {
continue
}
loc := hi.Location()
if loc == nil || loc.Priority <= maxWeight {
continue
}
maxWeight = loc.Priority
best = c
}
return best
}

// pickDERPNode chooses a node randomly given a list of nodes and a *rand.Rand.
func pickDERPNode(nodes []tailcfg.NodeView, r *rand.Rand) tailcfg.NodeView {
return nodes[r.Intn(len(nodes))]
}

// pickDERPRegion chooses a region randomly given a list of ints and a *rand.Rand
func pickDERPRegion(regions []int, r *rand.Rand) int {
return regions[r.Intn(len(regions))]
}

// 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, report *netcheck.Report) int {
min := slices.MinFunc(regions, func(i, j int) int {
const largeDuration time.Duration = math.MaxInt64
iLatency, ok := report.RegionLatency[i]
if !ok {
iLatency = largeDuration
}
jLatency, ok := report.RegionLatency[j]
if !ok {
jLatency = largeDuration
}
if c := cmp.Compare(iLatency, jLatency); c != 0 {
return c
}
return cmp.Compare(i, j)
})
latency, ok := report.RegionLatency[min]
if !ok || latency == 0 {
return 0
} else {
return min
}
}

// longLatDistance returns an estimated distance given the geographic coordinates of two locations, in degrees.
// The coordinates are separated into four separate float64 values.
// Value is returned in meters.
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
}

0 comments on commit fc74857

Please sign in to comment.