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-07-02 20:26:27 -07:00
committed by Simon Law
parent 932260511e
commit ca91eafce5
4 changed files with 99 additions and 3 deletions
+55 -2
View File
@@ -7,6 +7,7 @@ package localapi
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)