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:
parent
ab7749aed7
commit
301e59f398
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue