From ca91eafce588250fdd7e114e922b8b5adcf6e4bd Mon Sep 17 00:00:00 2001 From: Simon Law Date: Tue, 5 May 2026 18:53:44 -0700 Subject: [PATCH] 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 --- client/local/local.go | 14 +++++++++ cmd/tailscale/cli/exitnode.go | 15 ++++++++- feature/routecheck/localapi.go | 16 ++++++++++ ipn/localapi/localapi.go | 57 ++++++++++++++++++++++++++++++++-- 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/client/local/local.go b/client/local/local.go index 51817f63b..72793c9df 100644 --- a/client/local/local.go +++ b/client/local/local.go @@ -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) { diff --git a/cmd/tailscale/cli/exitnode.go b/cmd/tailscale/cli/exitnode.go index 7ba4859d7..8b8333121 100644 --- a/cmd/tailscale/cli/exitnode.go +++ b/cmd/tailscale/cli/exitnode.go @@ -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) } diff --git a/feature/routecheck/localapi.go b/feature/routecheck/localapi.go index 157674fdb..6f0df7ef6 100644 --- a/feature/routecheck/localapi.go +++ b/feature/routecheck/localapi.go @@ -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 +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 4ef4b33e4..2c880445f 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -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 isn’t 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 isn’t 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 don’t 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 don’t 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)