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 4, 2024
1 parent 92d3f64 commit a1665c4
Show file tree
Hide file tree
Showing 8 changed files with 911 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
}
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, name, and tailcfg.Location of a suggested exit node to connect to.
func (lc *LocalClient) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
body, err := lc.send(ctx, "GET", "/localapi/v0/suggest-exit-node", 200, nil)
if err != nil {
return apitype.ExitNodeSuggestionResponse{ID: "", Name: "", Location: tailcfg.Location{}}, fmt.Errorf("error %w: %s", err, body)
}
res, err := decodeJSON[apitype.ExitNodeSuggestionResponse](body)
if err != nil {
return apitype.ExitNodeSuggestionResponse{ID: "", Name: "", Location: tailcfg.Location{}}, err
}
return apitype.ExitNodeSuggestionResponse{ID: res.ID, Name: res.Name, Location: res.Location}, nil
}
40 changes: 39 additions & 1 deletion 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 @@ -97,11 +107,39 @@ 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 is available.")
return nil
} else {
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 {
var hasSuggestion bool
for _, peer := range peers {
if peer.HasCap(tailcfg.NodeAttrSuggestExitNode) {
hasSuggestion = true
}
}
return hasSuggestion
}

// 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
157 changes: 157 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 @@ -6203,3 +6205,158 @@ func mayDeref[T any](p *T) (v T) {
}
return *p
}

// SuggestExitNode returns a tailcfg.StableNodeID, name, and tailcfg.Location 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() (response apitype.ExitNodeSuggestionResponse, err error) {
b.mu.Lock()
lastReport := b.MagicConn().GetLastNetcheckReport(b.ctx)
netMap := b.netMap
b.mu.Unlock()
seed := time.Now().UnixNano()
return suggestExitNode(lastReport, netMap, seed)
}

func suggestExitNode(lastReport *netcheck.Report, netMap *netmap.NetworkMap, seed int64) (response apitype.ExitNodeSuggestionResponse, err error) {
var preferredExitNodeID tailcfg.StableNodeID
var preferredExitNodeName string
var preferredExitNodeLocation tailcfg.Location
if lastReport.PreferredDERP == 0 {
return apitype.ExitNodeSuggestionResponse{ID: preferredExitNodeID, Name: "", Location: tailcfg.Location{}}, errors.New("no preferred DERP, try again later")
}
var candidates []tailcfg.NodeView
peers := netMap.Peers
for _, peer := range peers {
if peer.CapMap().Has(tailcfg.NodeAttrSuggestExitNode) {
if online := peer.Online(); online != nil && !*online {
continue
}
if tsaddr.ContainsExitRoutes(peer.AllowedIPs()) {
candidates = append(candidates, peer)
}
}
}
if len(candidates) == 0 {
return apitype.ExitNodeSuggestionResponse{ID: preferredExitNodeID, Name: "", Location: tailcfg.Location{}}, nil
}
if len(candidates) == 1 {
hi := candidates[0].Hostinfo()
if hi.Valid() {
if loc := hi.Location(); loc != nil {
preferredExitNodeLocation = *loc
}
}
return apitype.ExitNodeSuggestionResponse{ID: candidates[0].StableID(), Name: candidates[0].Name(), Location: preferredExitNodeLocation}, nil
}

candidatesByRegion := make(map[int][]tailcfg.NodeView, len(netMap.DERPMap.Regions))
candidateDistanceMap := make(map[float64][]tailcfg.NodeView, len(candidates))
preferredDerp := netMap.DERPMap.Regions[lastReport.PreferredDERP]
regions := make([]int, 0, len(netMap.DERPMap.Regions))
var minDistance float64 = math.MaxFloat64
if preferredDerp == nil {
return apitype.ExitNodeSuggestionResponse{ID: preferredExitNodeID, Name: "", Location: tailcfg.Location{}}, errors.New("no preferred DERP, try again later")
}
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(regions, regionID) {
regions = append(regions, regionID)
}
candidatesByRegion[regionID] = append(candidatesByRegion[regionID], candidate)
}
} else {
if candidate.Hostinfo().Location().View().Valid() {
distance := longLatDistance(preferredDerp.Latitude, preferredDerp.Longitude, candidate.Hostinfo().Location().Latitude, candidate.Hostinfo().Location().Longitude)
if distance < minDistance {
minDistance = distance
}
candidateDistanceMap[distance] = append(candidateDistanceMap[distance], candidate)
}
}
}
}
minRegion := minLatencyDERPRegion(regions, lastReport)
if candidatesByRegion[minRegion] != nil {
chosen := pickDERPNode(candidatesByRegion[minRegion], seed)
preferredExitNodeID = chosen.StableID()
preferredExitNodeName = chosen.Name()
hi := chosen.Hostinfo()
if hi.Valid() && hi.Location().View().Valid() {
preferredExitNodeLocation = *hi.Location()
}
} else {
const minDistanceEpsilon = 100000 // pick mullvad nodes that are at most 100km away from the minimum distance found.
var pickFrom []tailcfg.NodeView
for candidateDistance, candidate := range candidateDistanceMap {
if candidateDistance <= minDistance+minDistanceEpsilon {
pickFrom = append(pickFrom, candidate...)
}
}
chosen := pickWeighted(pickFrom)
if chosen.Valid() {
preferredExitNodeID = chosen.StableID()
preferredExitNodeName = chosen.Name()
preferredExitNodeLocation = *chosen.Hostinfo().Location()
}
}
if preferredExitNodeID.IsZero() {
return apitype.ExitNodeSuggestionResponse{ID: preferredExitNodeID, Name: "", Location: preferredExitNodeLocation}, nil
}
return apitype.ExitNodeSuggestionResponse{ID: preferredExitNodeID, Name: preferredExitNodeName, Location: preferredExitNodeLocation}, nil
}

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

func pickDERPNode(nodes []tailcfg.NodeView, seed int64) tailcfg.NodeView {
r := rand.New(rand.NewSource(seed))
return nodes[r.Intn(len(nodes))]
}

func minLatencyDERPRegion(regions []int, lastReport *netcheck.Report) int {
minLatency := time.Duration(math.MaxInt64)
var minRegion int
for _, region := range regions {
latency, ok := lastReport.RegionLatency[region]
if !ok {
continue
}
if latency < minLatency {
minLatency = latency
minRegion = region
}
}
return minRegion
}

// longLatDistance returns an estimated distance given the geographic coordinates of two locations.
// The coordiantes are separated into four separate float64 values.

Check failure on line 6350 in ipn/ipnlocal/local.go

View workflow job for this annotation

GitHub Actions / lint

`coordiantes` is a misspelling of `coordinates` (misspell)
// Value is returned in meters. We tolerate up to 100km difference in accuracy.
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 a1665c4

Please sign in to comment.