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:
@@ -1512,6 +1512,20 @@ func (lc *Client) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggesti
|
|||||||
return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
|
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
|
// 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.
|
// be true if tailscale is running on Linux and tailscaled uses SO_MARK.
|
||||||
func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {
|
func (lc *Client) CheckSOMarkInUse(ctx context.Context) (bool, error) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/kballard/go-shellquote"
|
"github.com/kballard/go-shellquote"
|
||||||
"github.com/peterbourgon/ff/v3/ffcli"
|
"github.com/peterbourgon/ff/v3/ffcli"
|
||||||
"tailscale.com/envknob"
|
"tailscale.com/envknob"
|
||||||
|
"tailscale.com/feature/buildfeatures"
|
||||||
"tailscale.com/ipn/ipnstate"
|
"tailscale.com/ipn/ipnstate"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
"tailscale.com/util/slicesx"
|
"tailscale.com/util/slicesx"
|
||||||
@@ -43,6 +44,13 @@ func exitNodeCmd() *ffcli.Command {
|
|||||||
ShortUsage: "tailscale exit-node suggest",
|
ShortUsage: "tailscale exit-node suggest",
|
||||||
ShortHelp: "Suggest the best available exit node",
|
ShortHelp: "Suggest the best available exit node",
|
||||||
Exec: runExitNodeSuggest,
|
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 {
|
(func() []*ffcli.Command {
|
||||||
if !envknob.UseWIPCode() {
|
if !envknob.UseWIPCode() {
|
||||||
@@ -68,6 +76,7 @@ func exitNodeCmd() *ffcli.Command {
|
|||||||
|
|
||||||
var exitNodeArgs struct {
|
var exitNodeArgs struct {
|
||||||
filter string
|
filter string
|
||||||
|
probe bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func exitNodeSetUse(wantOn bool) func(ctx context.Context, args []string) error {
|
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.
|
// 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.
|
// 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 {
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("suggest exit node: %w", err)
|
return fmt.Errorf("suggest exit node: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,15 @@
|
|||||||
package routecheck
|
package routecheck
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
jsonv2 "github.com/go-json-experiment/json"
|
jsonv2 "github.com/go-json-experiment/json"
|
||||||
jsonv1 "github.com/go-json-experiment/json/v1"
|
jsonv1 "github.com/go-json-experiment/json/v1"
|
||||||
|
|
||||||
|
"tailscale.com/ipn/ipnlocal"
|
||||||
"tailscale.com/ipn/localapi"
|
"tailscale.com/ipn/localapi"
|
||||||
"tailscale.com/net/routecheck"
|
"tailscale.com/net/routecheck"
|
||||||
"tailscale.com/util/def"
|
"tailscale.com/util/def"
|
||||||
@@ -18,6 +21,7 @@ import (
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
localapi.Register("routecheck", serveRouteCheck)
|
localapi.Register("routecheck", serveRouteCheck)
|
||||||
|
localapi.HookRouteCheckRefresh.Set(routeCheckRefresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeRouteCheck handles the API endpoint that serves the routecheck Report.
|
// 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]
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package localapi
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -1814,16 +1815,68 @@ func dnsMessageTypeForString(s string) (t dnsmessage.Type, err error) {
|
|||||||
return 0, errors.New("unknown DNS message type: " + s)
|
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.
|
// 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) {
|
func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) {
|
||||||
if !buildfeatures.HasUseExitNode {
|
if !buildfeatures.HasUseExitNode {
|
||||||
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := h.b.SuggestExitNode()
|
res, err := h.b.SuggestExitNode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteErrorJSON(w, err)
|
WriteErrorJSON(w, err)
|
||||||
|
|||||||
Reference in New Issue
Block a user