Kaynağa Gözat

tailcfg,ipn/localapi,client/tailscale: add QueryFeature endpoint

Updates tailscale/corp#10577

Signed-off-by: Sonia Appasamy <[email protected]>
Sonia Appasamy 2 yıl önce
ebeveyn
işleme
301e59f398

+ 21 - 0
client/tailscale/localclient.go

@@ -1124,6 +1124,27 @@ func (lc *LocalClient) DeleteProfile(ctx context.Context, profile ipn.ProfileID)
 	return err
 }
 
+// QueryFeature makes a request for instructions on how to enable a
+// feature, such as Funnel, for the node's tailnet.
+//
+// This request itself does not directly enable the feature on behalf
+// of the node, but rather returns information that can be presented
+// to the acting user about where/how to enable the feature.
+//
+// If relevant, this includes a control URL the user can visit to
+// explicitly consent to using the feature. LocalClient.WatchIPNBus
+// can be used to block on the feature being enabled.
+//
+// 2023-08-02: Valid feature values are "serve" and "funnel".
+func (lc *LocalClient) QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) {
+	v := url.Values{"feature": {feature}}
+	body, err := lc.send(ctx, "POST", "/localapi/v0/query-feature?"+v.Encode(), 200, nil)
+	if err != nil {
+		return nil, fmt.Errorf("error %w: %s", err, body)
+	}
+	return decodeJSON[*tailcfg.QueryFeatureResponse](body)
+}
+
 func (lc *LocalClient) DebugDERPRegion(ctx context.Context, regionIDOrCode string) (*ipnstate.DebugDERPRegionReport, error) {
 	v := url.Values{"region": {regionIDOrCode}}
 	body, err := lc.send(ctx, "POST", "/localapi/v0/debug-derp-region?"+v.Encode(), 200, nil)

+ 2 - 0
cmd/tailscale/cli/serve.go

@@ -24,6 +24,7 @@ import (
 	"github.com/peterbourgon/ff/v3/ffcli"
 	"tailscale.com/ipn"
 	"tailscale.com/ipn/ipnstate"
+	"tailscale.com/tailcfg"
 	"tailscale.com/util/mak"
 	"tailscale.com/version"
 )
@@ -128,6 +129,7 @@ type localServeClient interface {
 	Status(context.Context) (*ipnstate.Status, error)
 	GetServeConfig(context.Context) (*ipn.ServeConfig, error)
 	SetServeConfig(context.Context, *ipn.ServeConfig) error
+	QueryFeature(context.Context, string) (*tailcfg.QueryFeatureResponse, error)
 }
 
 // serveEnv is the environment the serve command runs within. All I/O should be

+ 4 - 0
cmd/tailscale/cli/serve_test.go

@@ -782,6 +782,10 @@ func (lc *fakeLocalServeClient) SetServeConfig(ctx context.Context, config *ipn.
 	return nil
 }
 
+func (lc *fakeLocalServeClient) QueryFeature(context.Context, string) (*tailcfg.QueryFeatureResponse, error) {
+	return nil, nil
+}
+
 // exactError returns an error checker that wants exactly the provided want error.
 // If optName is non-empty, it's used in the error message.
 func exactErr(want error, optName ...string) func(error) string {

+ 61 - 0
ipn/localapi/localapi.go

@@ -113,6 +113,7 @@ var handler = map[string]localAPIHandler{
 	"upload-client-metrics":       (*Handler).serveUploadClientMetrics,
 	"watch-ipn-bus":               (*Handler).serveWatchIPNBus,
 	"whois":                       (*Handler).serveWhoIs,
+	"query-feature":               (*Handler).serveQueryFeature,
 }
 
 func randHex(n int) string {
@@ -1932,6 +1933,66 @@ func (h *Handler) serveProfiles(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// serveQueryFeature makes a request to the "/machine/feature/query"
+// Noise endpoint to get instructions on how to enable a feature, such as
+// Funnel, for the node's tailnet.
+//
+// This request itself does not directly enable the feature on behalf of
+// the node, but rather returns information that can be presented to the
+// acting user about where/how to enable the feature. If relevant, this
+// includes a control URL the user can visit to explicitly consent to
+// using the feature.
+//
+// See tailcfg.QueryFeatureResponse for full response structure.
+func (h *Handler) serveQueryFeature(w http.ResponseWriter, r *http.Request) {
+	feature := r.FormValue("feature")
+	switch {
+	case !h.PermitRead:
+		http.Error(w, "access denied", http.StatusForbidden)
+		return
+	case r.Method != httpm.POST:
+		http.Error(w, "use POST", http.StatusMethodNotAllowed)
+		return
+	case feature == "":
+		http.Error(w, "missing feature", http.StatusInternalServerError)
+		return
+	}
+	nm := h.b.NetMap()
+	if nm == nil {
+		http.Error(w, "no netmap", http.StatusServiceUnavailable)
+		return
+	}
+
+	b, err := json.Marshal(&tailcfg.QueryFeatureRequest{
+		NodeKey: nm.NodeKey,
+		Feature: feature,
+	})
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	req, err := http.NewRequestWithContext(r.Context(),
+		"POST", "https://unused/machine/feature/query", bytes.NewReader(b))
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	resp, err := h.b.DoNoiseRequest(req)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	defer resp.Body.Close()
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(resp.StatusCode)
+	if _, err := io.Copy(w, resp.Body); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
 func defBool(a string, def bool) bool {
 	if a == "" {
 		return def

+ 40 - 0
tailcfg/tailcfg.go

@@ -2257,6 +2257,46 @@ type SSHRecordingAttempt struct {
 	FailureMessage string
 }
 
+// QueryFeatureRequest is a request sent to "/machine/feature/query"
+// to get instructions on how to enable a feature, such as Funnel,
+// for the node's tailnet.
+//
+// See QueryFeatureResponse for response structure.
+type QueryFeatureRequest struct {
+	// Feature is the string identifier for a feature.
+	Feature string `json:",omitempty"`
+	// NodeKey is the client's current node key.
+	NodeKey key.NodePublic `json:",omitempty"`
+}
+
+// QueryFeatureResponse is the response to an QueryFeatureRequest.
+type QueryFeatureResponse struct {
+	// Complete is true when the feature is already enabled.
+	Complete bool `json:",omitempty"`
+
+	// Text holds lines to display in the CLI with information
+	// about the feature and how to enable it.
+	//
+	// Lines are separated by newline characters. The final
+	// newline may be omitted.
+	Text string `json:",omitempty"`
+
+	// URL is the link for the user to visit to take action on
+	// enabling the feature.
+	//
+	// When empty, there is no action for this user to take.
+	URL string `json:",omitempty"`
+
+	// WaitOn specifies the self node capability required to use
+	// the feature. The CLI can watch for changes to the presence,
+	// of this capability, and once included, can proceed with
+	// using the feature.
+	//
+	// If WaitOn is empty, the user does not have an action that
+	// the CLI should block on.
+	WaitOn string `json:",omitempty"`
+}
+
 // OverTLSPublicKeyResponse is the JSON response to /key?v=<n>
 // over HTTPS (regular TLS) to the Tailscale control plane server,
 // where the 'v' argument is the client's current capability version