diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index 93088c8b0..f0c41f971 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -1417,12 +1417,16 @@ func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, return &cv, nil } -func (lc *LocalClient) SuggestExitNode(ctx context.Context) error { +func (lc *LocalClient) SuggestExitNode(ctx context.Context) (*tailcfg.StableNodeID, error) { body, err := lc.send(ctx, "POST", "/localapi/v0/suggest-exit-node", 200, nil) if err != nil { - return fmt.Errorf("error %w: %s", err, body) + return nil, fmt.Errorf("error %w: %s", err, body) } - return nil + nodeID, err := decodeJSON[tailcfg.StableNodeID](body) + if err != nil { + return nil, err + } + return &nodeID, nil } // IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus. diff --git a/cmd/tailscale/cli/exitnode.go b/cmd/tailscale/cli/exitnode.go index 549ecfc33..ab7475953 100644 --- a/cmd/tailscale/cli/exitnode.go +++ b/cmd/tailscale/cli/exitnode.go @@ -16,6 +16,7 @@ "github.com/peterbourgon/ff/v3/ffcli" xmaps "golang.org/x/exp/maps" + "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" ) @@ -107,16 +108,32 @@ 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. Or `tailscale exit-node suggest` to have Tailscale pick an exit node for you.") return nil } func runExitNodeSuggest(ctx context.Context, args []string) error { - err := localClient.SuggestExitNode(ctx) + var suggestedNodeID *tailcfg.StableNodeID + suggestedNodeID, err := localClient.SuggestExitNode(ctx) + if suggestedNodeID == nil { + fmt.Println("Unable to suggest exit node") + } else { + fmt.Printf("Chosen exit node id: %v", *suggestedNodeID) + } if err != nil { return fmt.Errorf("Failed to suggest exit node. Error: %v", err) } + _, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{ + ExitNodeID: *suggestedNodeID, + }, + ExitNodeIDSet: true, + }) + + if err != nil { + return fmt.Errorf("Failed to set suggested exit node. Error: %v", err) + } return nil } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 82da11b40..ce5edd2f5 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -309,8 +309,7 @@ type LocalBackend struct { clock tstime.Clock // Last ClientVersion received in MapResponse, guarded by mu. - lastClientVersion *tailcfg.ClientVersion - suggestedExitNodeMap map[tailcfg.NodeID]tailcfg.DERPRegion + lastClientVersion *tailcfg.ClientVersion } type updateStatus struct { @@ -5913,35 +5912,64 @@ func mayDeref[T any](p *T) (v T) { return *p } -func (b *LocalBackend) SuggestExitNode() error { - //b.mu.Lock() +func (b *LocalBackend) SuggestExitNode() (*tailcfg.StableNodeID, error) { + b.mu.Lock() netMap := b.netMap + if netMap == nil { + b.mu.Unlock() + return nil, errors.New("no netmap") + } peers := netMap.Peers lastReport := b.MagicConn().GetLastNetcheckReport() var fastestRegionLatency = time.Duration(math.MaxInt64) var preferredExitNodeID tailcfg.StableNodeID + peerRegionMap := make(map[int][]tailcfg.NodeView) for _, peer := range peers { - if tsaddr.ContainsExitRoutes(peer.AllowedIPs()) && strings.HasPrefix(peer.DERP(), derpPrefix) { + if online := peer.Online(); online != nil && !*online { + continue + } + if tsaddr.ContainsExitRoutes(peer.AllowedIPs()) /*&& strings.HasPrefix(peer.DERP(), derpPrefix)*/ { ipp, _ := netip.ParseAddrPort(peer.DERP()) regionID := int(ipp.Port()) - if lastReport.RegionLatency[regionID] < fastestRegionLatency { + regionLatency, ok := lastReport.RegionLatency[regionID] + peerRegionMap[regionID] = append(peerRegionMap[regionID], peer) + b.logf("region %d latency %v(%v) node id %v", regionID, regionLatency, ok, peer.Name()) + if ok && regionLatency < fastestRegionLatency { fastestRegionLatency = lastReport.RegionLatency[regionID] preferredExitNodeID = peer.StableID() b.logf("fastest region latency %v preferred exit node id %v", lastReport.RegionLatency[regionID], peer.StableID()) } } } - // b.mu.Unlock() - prefs := b.Prefs().AsStruct() - prefs.ExitNodeID = preferredExitNodeID - _, err := b.EditPrefs(&ipn.MaskedPrefs{ - Prefs: ipn.Prefs{ - ExitNodeID: preferredExitNodeID, - }, - ExitNodeIDSet: true, - }) - if err != nil { - return fmt.Errorf("Failed to suggest exit node %v, err %v", preferredExitNodeID, err) + b.logf("self derp %v", b.netMap.SelfNode.DERP()) + ipp, _ := netip.ParseAddrPort(netMap.SelfNode.DERP()) + selfDerpRegionID := int(ipp.Port()) + b.logf("self derp region id %v", selfDerpRegionID) + sameRegionNodes, ok := peerRegionMap[selfDerpRegionID] + b.mu.Unlock() + if ok { + preferredExitNodeID = balancedPick(netMap.SelfNode, sameRegionNodes).StableID() } - return nil + //b.logf("peer region map %v", peerRegionMap) + b.logf("chosen exit node id %v", preferredExitNodeID) + if preferredExitNodeID.IsZero() { + return &preferredExitNodeID, fmt.Errorf("Unable to choose exit node") + } + return &preferredExitNodeID, nil } + +func balancedPick(selfNode tailcfg.NodeView, candidates []tailcfg.NodeView) tailcfg.NodeView { + if len(candidates) == 1 { + return candidates[0] + } else { + sort.Slice(candidates, func(i, j int) bool { return candidates[i].ID() < candidates[j].ID() }) + + mappedID := selfNode.ID() % 512 + chosen := candidates[int(math.Floor(float64(mappedID)/512*float64(len(candidates))))] + return chosen + } +} + +/*func measureLatency(ctx context.Context, derpRegion *DERPRegion, p *ping.Pinger) (time.Duration, error) { + node := derpRegion.Nodes[0] +} */ diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 7301170b3..bc407e07a 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -2515,9 +2515,10 @@ func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) { http.Error(w, "want POST", http.StatusBadRequest) return } - err := h.b.SuggestExitNode() + suggestedExitNodeID, err := h.b.SuggestExitNode() if err != nil { writeErrorJSON(w, err) return } + json.NewEncoder(w).Encode(suggestedExitNodeID) } diff --git a/wgengine/magicsock/magicsock.go b/wgengine/magicsock/magicsock.go index d1c0859c7..7912e6256 100644 --- a/wgengine/magicsock/magicsock.go +++ b/wgengine/magicsock/magicsock.go @@ -3010,5 +3010,6 @@ func getPeerMTUsProbedMetric(mtu tstun.WireMTU) *clientmetric.Metric { } func (c *Conn) GetLastNetcheckReport() *netcheck.Report { - return c.lastNetCheckReport.Load() + report, _ := c.updateNetInfo(c.connCtx) + return report }