mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-20 21:23:07 +08:00
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:
parent
932260511e
commit
ca91eafce5
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user