cmd/tailscale/cli: add tailscale exit-node suggest --force-probe

Add a new `--force-probe` flag to `tailscale exit-node suggest` that
waits for a routecheck.Refresh to finish before suggesting an exit
node.

This flag is currently hidden from the help text, but this flag is a
hint to the user that exit-node suggestions are based on routecheck
reachability reports.

Updates #17366
Updates tailscale/corp#33033

Signed-off-by: Simon Law <sfllaw@tailscale.com>
This commit is contained in:
Simon Law 2026-05-05 18:53:44 -07:00 committed by Simon Law
parent 932260511e
commit ca91eafce5
4 changed files with 99 additions and 3 deletions

View File

@ -1512,6 +1512,20 @@ func (lc *Client) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggesti
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
}
// SuggestExitNodeWithProbe requests an exit node suggestion based on an immediate routecheck probe,
// waits for the probe to finish, and returns the exit node's details.
func (lc *Client) SuggestExitNodeWithProbe(ctx context.Context) (apitype.ExitNodeSuggestionResponse, error) {
if !buildfeatures.HasRouteCheck {
return apitype.ExitNodeSuggestionResponse{}, feature.ErrUnavailable
}
v := url.Values{"probe": {"true"}}
body, err := lc.send(ctx, "POST", "/localapi/v0/suggest-exit-node?"+v.Encode(), 200, nil)
if err != nil {
return apitype.ExitNodeSuggestionResponse{}, err
}
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
}
// CheckSOMarkInUse reports whether the socket mark option is in use. This will only
// be true if tailscale is running on Linux and tailscaled uses SO_MARK.
func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {

View File

@ -16,6 +16,7 @@
"github.com/kballard/go-shellquote"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/envknob"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/slicesx"
@ -43,6 +44,13 @@ func exitNodeCmd() *ffcli.Command {
ShortUsage: "tailscale exit-node suggest",
ShortHelp: "Suggest the best available exit node",
Exec: runExitNodeSuggest,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("suggest")
if buildfeatures.HasRouteCheck {
fs.BoolVar(&exitNodeArgs.probe, "force-probe", false, hidden+"perform a routecheck probe before suggesting")
}
return fs
})(),
}},
(func() []*ffcli.Command {
if !envknob.UseWIPCode() {
@ -68,6 +76,7 @@ func exitNodeCmd() *ffcli.Command {
var exitNodeArgs struct {
filter string
probe bool
}
func exitNodeSetUse(wantOn bool) func(ctx context.Context, args []string) error {
@ -148,7 +157,11 @@ func runExitNodeList(ctx context.Context, args []string) error {
// 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)
suggestExitNode := localClient.SuggestExitNode
if exitNodeArgs.probe {
suggestExitNode = localClient.SuggestExitNodeWithProbe
}
res, err := suggestExitNode(ctx)
if err != nil {
return fmt.Errorf("suggest exit node: %w", err)
}

View File

@ -4,12 +4,15 @@
package routecheck
import (
"context"
"errors"
"net/http"
"time"
jsonv2 "github.com/go-json-experiment/json"
jsonv1 "github.com/go-json-experiment/json/v1"
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/localapi"
"tailscale.com/net/routecheck"
"tailscale.com/util/def"
@ -18,6 +21,7 @@
func init() {
localapi.Register("routecheck", serveRouteCheck)
localapi.HookRouteCheckRefresh.Set(routeCheckRefresh)
}
// ServeRouteCheck handles the API endpoint that serves the routecheck Report.
@ -66,3 +70,15 @@ func clampRouteCheckTimeout(timeout time.Duration) time.Duration {
}
return min(max(0, timeout), 60*time.Second) // clamp to [0s, 60s]
}
// routeCheckRefresh is a localapi hook for refreshing the [routecheck.Client.Report].
// If the timeout is 0, all probes will timeout immediately.
// If the timeout is negative, then all probes will use the [DefaultTimeout].
func routeCheckRefresh(b *ipnlocal.LocalBackend, ctx context.Context, timeout time.Duration) error {
rc := ClientFor(b)
if rc == nil {
return errors.New("routecheck is not enabled")
}
_, err := rc.Refresh(ctx, clampRouteCheckTimeout(timeout))
return err
}

View File

@ -7,6 +7,7 @@
import (
"bytes"
"cmp"
"context"
"crypto/subtle"
"encoding/json"
"errors"
@ -1814,16 +1815,68 @@ func dnsMessageTypeForString(s string) (t dnsmessage.Type, err error) {
return 0, errors.New("unknown DNS message type: " + s)
}
// HookRouteCheckRefresh is the hook to request a refresh of the routecheck Report
// for this LocalBackend.
// It is used by serveSuggestExitNode to probe for reachable exit nodes
// before it makes a suggestion.
var HookRouteCheckRefresh feature.Hook[func(*ipnlocal.LocalBackend, context.Context, time.Duration) error]
// serveSuggestExitNode serves a POST endpoint for returning a suggested exit node.
// If the probe query parameter is true,
// then a new routecheck report will be probed
// so that the suggested exit node isnt based on stale reachability data.
// Combined with probe=true:
// if the timeout query parameter is 0, any probes will immediately timeout;
// if the timeout is positive, probes will take that duration before timing out;
// if the timeout is negative, probes will use the default routecheck timeout.
func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasUseExitNode {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
if r.Method != httpm.GET {
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
switch r.Method {
case httpm.GET:
// The GET method is inappropriate because it isnt cacheable.
// However, we retain it for backwards compatibility.
//
// The original implementation used to only allow GET requests,
// but since this endpoint powers the `tailscale exit-node suggest` command
// whose results can change based on the shape of the network,
// it was never a proper GET method in the first place.
//
// Now that the suggestions are backed by routecheck
// and we provide the user the ability to trigger a probe,
// this is obviously a POST method because it has side-effects.
// However, we dont want to break existing clients,
// so we silently support the old GET method
// without the extra query parameters.
//
// This is also why the default case returns a "want POST" error
// and not an "only POST allowed" like the other endpoints.
// We still accept GET requests but we dont want them.
case httpm.POST:
if !def.Bool(r.FormValue("probe"), false) {
break
}
timeout := def.Duration(r.FormValue("timeout"), -1)
// Force routecheck to probe for a new report
// and use it to suggest an exit node.
routecheckRefresh := HookRouteCheckRefresh.GetOrNil()
if routecheckRefresh == nil {
break
}
if err := routecheckRefresh(h.b, r.Context(), timeout); err != nil {
WriteErrorJSON(w, err)
return
}
default:
// Discourage the GET method:
http.Error(w, "want POST", http.StatusMethodNotAllowed)
return
}
res, err := h.b.SuggestExitNode()
if err != nil {
WriteErrorJSON(w, err)