diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go index cd53e062b..e0b9592b5 100644 --- a/client/tailscale/localclient.go +++ b/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) diff --git a/cmd/tailscale/cli/serve.go b/cmd/tailscale/cli/serve.go index 3ad0bb432..7242d2e7b 100644 --- a/cmd/tailscale/cli/serve.go +++ b/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 diff --git a/cmd/tailscale/cli/serve_test.go b/cmd/tailscale/cli/serve_test.go index 3d5b8f1f6..398ff7fae 100644 --- a/cmd/tailscale/cli/serve_test.go +++ b/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 { diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 0bc2870d2..eda32ac57 100644 --- a/ipn/localapi/localapi.go +++ b/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 diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 803e74017..334aa3101 100644 --- a/tailcfg/tailcfg.go +++ b/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= // over HTTPS (regular TLS) to the Tailscale control plane server, // where the 'v' argument is the client's current capability version