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

Updates tailscale/corp#10577

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy 2023-08-02 10:56:18 -04:00 committed by Sonia Appasamy
parent ab7749aed7
commit 301e59f398
5 changed files with 128 additions and 0 deletions

View File

@ -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)

View File

@ -24,6 +24,7 @@
"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

View File

@ -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 {

View File

@ -113,6 +113,7 @@
"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

View File

@ -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