diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index cb8670d8b..93088c8b0 100644 --- a/client/tailscale/localclient.go +++ b/client/tailscale/localclient.go @@ -1417,6 +1417,14 @@ func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, return &cv, nil } +func (lc *LocalClient) SuggestExitNode(ctx context.Context) 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 +} + // IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus. // It's returned by LocalClient.WatchIPNBus. // diff --git a/cmd/tailscale/cli/set.go b/cmd/tailscale/cli/set.go index 02d4f5a06..a5623cc33 100644 --- a/cmd/tailscale/cli/set.go +++ b/cmd/tailscale/cli/set.go @@ -64,7 +64,7 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet { setf.StringVar(&setArgs.profileName, "nickname", "", "nickname for the current account") setf.BoolVar(&setArgs.acceptRoutes, "accept-routes", false, "accept routes advertised by other Tailscale nodes") setf.BoolVar(&setArgs.acceptDNS, "accept-dns", false, "accept DNS configuration from the admin panel") - setf.StringVar(&setArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node") + setf.StringVar(&setArgs.exitNodeIP, "exit-node", "", "Tailscale exit node (IP or base name) for internet traffic, or empty string to not use an exit node. Input suggest as the string for Tailscale to pick the best exit node.") setf.BoolVar(&setArgs.exitNodeAllowLANAccess, "exit-node-allow-lan-access", false, "Allow direct access to the local network when routing traffic via an exit node") setf.BoolVar(&setArgs.shieldsUp, "shields-up", false, "don't allow incoming connections") setf.BoolVar(&setArgs.runSSH, "ssh", false, "run an SSH server, permitting access per tailnet admin's declared policy") @@ -128,12 +128,19 @@ func runSet(ctx context.Context, args []string) (retErr error) { } if setArgs.exitNodeIP != "" { - if err := maskedPrefs.Prefs.SetExitNodeIP(setArgs.exitNodeIP, st); err != nil { - var e ipn.ExitNodeLocalIPError - if errors.As(err, &e) { - return fmt.Errorf("%w; did you mean --advertise-exit-node?", err) + if setArgs.exitNodeIP == "suggest" { + err := localClient.SuggestExitNode(ctx) + if err != nil { + return err + } + } else { + if err := maskedPrefs.Prefs.SetExitNodeIP(setArgs.exitNodeIP, st); err != nil { + var e ipn.ExitNodeLocalIPError + if errors.As(err, &e) { + return fmt.Errorf("%w; did you mean --advertise-exit-node?", err) + } + return err } - return err } } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 1312e37dc..82da11b40 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -13,6 +13,7 @@ "io" "log" "maps" + "math" "net" "net/http" "net/netip" @@ -107,6 +108,8 @@ var controlDebugFlags = getControlDebugFlags() +const derpPrefix = "127.3.3.40:" + func getControlDebugFlags() []string { if e := envknob.String("TS_DEBUG_CONTROL_FLAGS"); e != "" { return strings.Split(e, ",") @@ -306,7 +309,8 @@ type LocalBackend struct { clock tstime.Clock // Last ClientVersion received in MapResponse, guarded by mu. - lastClientVersion *tailcfg.ClientVersion + lastClientVersion *tailcfg.ClientVersion + suggestedExitNodeMap map[tailcfg.NodeID]tailcfg.DERPRegion } type updateStatus struct { @@ -1431,7 +1435,6 @@ func setExitNodeID(prefs *ipn.Prefs, nm *netmap.NetworkMap) (prefsChanged bool) return oldExitNodeID != prefs.ExitNodeID } } - return prefsChanged } @@ -5909,3 +5912,36 @@ func mayDeref[T any](p *T) (v T) { } return *p } + +func (b *LocalBackend) SuggestExitNode() error { + //b.mu.Lock() + netMap := b.netMap + peers := netMap.Peers + lastReport := b.MagicConn().GetLastNetcheckReport() + var fastestRegionLatency = time.Duration(math.MaxInt64) + var preferredExitNodeID tailcfg.StableNodeID + for _, peer := range peers { + if tsaddr.ContainsExitRoutes(peer.AllowedIPs()) && strings.HasPrefix(peer.DERP(), derpPrefix) { + ipp, _ := netip.ParseAddrPort(peer.DERP()) + regionID := int(ipp.Port()) + if lastReport.RegionLatency[regionID] < 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) + } + return nil +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 4559a3997..7301170b3 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -129,6 +129,7 @@ "update/check": (*Handler).serveUpdateCheck, "update/install": (*Handler).serveUpdateInstall, "update/progress": (*Handler).serveUpdateProgress, + "suggest-exit-node": (*Handler).serveSuggestExitNode, } var ( @@ -2504,3 +2505,19 @@ func (h *Handler) serveUpdateProgress(w http.ResponseWriter, r *http.Request) { // User-visible LocalAPI endpoints. metricFilePutCalls = clientmetric.NewCounter("localapi_file_put") ) + +func (h *Handler) serveSuggestExitNode(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 + } + err := h.b.SuggestExitNode() + if err != nil { + writeErrorJSON(w, err) + return + } +} diff --git a/ipn/prefs.go b/ipn/prefs.go index 7bfbd613f..a35c990f6 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -799,6 +799,7 @@ func (p *Prefs) SetExitNodeIP(s string, st *ipnstate.Status) error { if err == nil { p.ExitNodeIP = ip } + return err }