diff --git a/constants.go b/constants.go index ff9356f2b63d6..79f97ae24bfaf 100644 --- a/constants.go +++ b/constants.go @@ -25,9 +25,6 @@ import ( "github.com/gravitational/trace" ) -// WebAPIVersion is a current webapi version -const WebAPIVersion = "v1" - const ( // SSHAuthSock is the environment variable pointing to the // Unix socket the SSH agent is running on. diff --git a/lib/auth/trustedcluster.go b/lib/auth/trustedcluster.go index 443cb6579013e..a02e8f4b74de6 100644 --- a/lib/auth/trustedcluster.go +++ b/lib/auth/trustedcluster.go @@ -679,7 +679,9 @@ func (a *Server) sendValidateRequestToProxy(ctx context.Context, host string, va opts = append(opts, roundtrip.HTTPClient(insecureWebClient)) } - clt, err := roundtrip.NewClient(proxyAddr.String(), teleport.WebAPIVersion, opts...) + // We do not add the version prefix since web api endpoints will + // contain differing version prefixes. + clt, err := roundtrip.NewClient(proxyAddr.String(), "" /* version prefix */, opts...) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/https_client.go b/lib/client/https_client.go index 66293120843b2..f1b6a0cee8654 100644 --- a/lib/client/https_client.go +++ b/lib/client/https_client.go @@ -28,7 +28,6 @@ import ( "github.com/gravitational/trace" "golang.org/x/net/http/httpproxy" - "github.com/gravitational/teleport" tracehttp "github.com/gravitational/teleport/api/observability/tracing/http" apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/lib/httplib" @@ -62,7 +61,9 @@ func httpTransport(insecure bool, pool *x509.CertPool) *http.Transport { func NewWebClient(url string, opts ...roundtrip.ClientParam) (*WebClient, error) { opts = append(opts, roundtrip.SanitizerEnabled(true)) - clt, err := roundtrip.NewClient(url, teleport.WebAPIVersion, opts...) + // We do not add the version prefix since web api endpoints will contain + // differing version prefixes. + clt, err := roundtrip.NewClient(url, "" /* version prefix */, opts...) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/weblogin_test.go b/lib/client/weblogin_test.go index cca05b892fe2b..1008308411d50 100644 --- a/lib/client/weblogin_test.go +++ b/lib/client/weblogin_test.go @@ -74,7 +74,7 @@ func TestHostCredentialsHttpFallback(t *testing.T) { // Start an http server (not https) so that the request only succeeds // if the fallback occurs. var handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { - if r.RequestURI != "/v1/webapi/host/credentials" { + if r.RequestURI != "/webapi/host/credentials" { w.WriteHeader(http.StatusNotFound) return } diff --git a/lib/httplib/httplib.go b/lib/httplib/httplib.go index f241f6d36ddb8..c98342f4e09a6 100644 --- a/lib/httplib/httplib.go +++ b/lib/httplib/httplib.go @@ -33,6 +33,7 @@ import ( "strconv" "strings" + "github.com/coreos/go-semver/semver" "github.com/gravitational/roundtrip" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" @@ -211,6 +212,51 @@ func ConvertResponse(re *roundtrip.Response, err error) (*roundtrip.Response, er return re, trace.ReadError(re.Code(), re.Bytes()) } +// ProxyVersion describes the parts of a Proxy semver +// version in the format: major.minor.patch-preRelease +type ProxyVersion struct { + // Major is the first part of version. + Major int64 `json:"major"` + // Minor is the second part of version. + Minor int64 `json:"minor"` + // Patch is the third part of version. + Patch int64 `json:"patch"` + // PreRelease is only defined if there was a hyphen + // and a word at the end of version eg: the prerelease + // value of version 18.0.0-dev is "dev". + PreRelease string `json:"preRelease"` + // String contains the whole version. + String string `json:"string"` +} + +// RouteNotFoundResponse writes a JSON error reply containing +// a not found error, a Version object, and a not found HTTP status code. +func RouteNotFoundResponse(w http.ResponseWriter, proxyVersion string) { + SetDefaultSecurityHeaders(w.Header()) + + errObj := &trace.TraceErr{ + Err: trace.NotFound("path not found"), + } + + ver, err := semver.NewVersion(proxyVersion) + if err != nil { + slog.Debug("Error parsing Teleport proxy semver version", "err", err) + } else { + verObj := ProxyVersion{ + Major: ver.Major, + Minor: ver.Minor, + Patch: ver.Patch, + String: proxyVersion, + PreRelease: string(ver.PreRelease), + } + fields := make(map[string]interface{}) + fields["proxyVersion"] = verObj + errObj.Fields = fields + } + + roundtrip.ReplyJSON(w, http.StatusNotFound, errObj) +} + // ParseBool will parse boolean variable from url query // returns value, ok, error func ParseBool(q url.Values, name string) (bool, bool, error) { diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index d8d620ce73ac6..43e499f8f453f 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -446,8 +446,6 @@ func (h *APIHandler) Close() error { // NewHandler returns a new instance of web proxy handler func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) { - const apiPrefix = "/" + teleport.WebAPIVersion - cfg.SetDefaults() h := &Handler{ @@ -612,13 +610,31 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) { h.nodeWatcher = cfg.NodeWatcher } - routingHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // ensure security headers are set for all responses - httplib.SetDefaultSecurityHeaders(w.Header()) - - // request is going to the API? - if strings.HasPrefix(r.URL.Path, apiPrefix) { - http.StripPrefix(apiPrefix, h).ServeHTTP(w, r) + const v1Prefix = "/v1" + notFoundRoutingHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Request is going to the API? + // If no routes were matched, it could be because it's a path with `v1` prefix + // (eg: the Teleport web app will call "most" endpoints with v1 prefixed). + // + // `v1` paths are not defined with `v1` prefix. If the path turns out to be prefixed + // with `v1`, it will be stripped and served again. Historically, that's how it started + // and should be kept that way to prevent breakage. + // + // v2+ prefixes will be expected by both caller and definition and will not be stripped. + if strings.HasPrefix(r.URL.Path, v1Prefix) { + pathParts := strings.Split(r.URL.Path, "/") + if len(pathParts) > 2 { + // check against known second part of path to ensure we + // aren't allowing paths like /v1/v2/webapi + // part[0] is empty space from leading slash "/" + // part[1] is the prefix "v1" + switch pathParts[2] { + case "webapi", "enterprise", "scripts", ".well-known", "workload-identity": + http.StripPrefix(v1Prefix, h).ServeHTTP(w, r) + return + } + } + httplib.RouteNotFoundResponse(w, teleport.Version) return } @@ -670,11 +686,12 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) { h.logger.ErrorContext(r.Context(), "Failed to execute index page template", "error", err) } } else { - http.NotFound(w, r) + httplib.RouteNotFoundResponse(w, teleport.Version) + return } }) - h.NotFound = routingHandler + h.NotFound = notFoundRoutingHandler if cfg.PluginRegistry != nil { if err := cfg.PluginRegistry.RegisterProxyWebHandlers(h); err != nil { @@ -867,8 +884,12 @@ func (h *Handler) bindDefaultEndpoints() { h.POST("/webapi/tokens", h.WithAuth(h.upsertTokenHandle)) // used for updating a token h.PUT("/webapi/tokens", h.WithAuth(h.upsertTokenHandle)) - // used for creating tokens used during guided discover flows + // TODO(kimlisa): DELETE IN 19.0 - Replaced by /v2/webapi/token endpoint + // MUST delete with related code found in web/packages/teleport/src/services/joinToken/joinToken.ts(fetchJoinToken) h.POST("/webapi/token", h.WithAuth(h.createTokenForDiscoveryHandle)) + // used for creating tokens used during guided discover flows + // v2 endpoint processes "suggestedLabels" field + h.POST("/v2/webapi/token", h.WithAuth(h.createTokenForDiscoveryHandle)) h.GET("/webapi/tokens", h.WithAuth(h.getTokens)) h.DELETE("/webapi/tokens", h.WithAuth(h.deleteToken)) @@ -1000,7 +1021,11 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/scripts/integrations/configure/deployservice-iam.sh", h.WithLimiter(h.awsOIDCConfigureDeployServiceIAM)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/ec2", h.WithClusterAuth(h.awsOIDCListEC2)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/eksclusters", h.WithClusterAuth(h.awsOIDCListEKSClusters)) + // TODO(kimlisa): DELETE IN 19.0 - replaced by /v2/webapi/sites/:site/integrations/aws-oidc/:name/enrolleksclusters + // MUST delete with related code found in web/packages/teleport/src/services/integrations/integrations.ts(enrollEksClusters) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/enrolleksclusters", h.WithClusterAuth(h.awsOIDCEnrollEKSClusters)) + // v2 endpoint introduces "extraLabels" field. + h.POST("/v2/webapi/sites/:site/integrations/aws-oidc/:name/enrolleksclusters", h.WithClusterAuth(h.awsOIDCEnrollEKSClusters)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/ec2ice", h.WithClusterAuth(h.awsOIDCListEC2ICE)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/deployec2ice", h.WithClusterAuth(h.awsOIDCDeployEC2ICE)) h.POST("/webapi/sites/:site/integrations/aws-oidc/:name/securitygroups", h.WithClusterAuth(h.awsOIDCListSecurityGroups)) diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index b220d593bab5b..429de42d3f338 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -49,6 +49,7 @@ import ( "testing" "time" + "github.com/coreos/go-semver/semver" "github.com/gogo/protobuf/proto" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -464,7 +465,7 @@ func newWebSuiteWithConfig(t *testing.T, cfg webSuiteConfig) *WebSuite { // Expired sessions are purged immediately var sessionLingeringThreshold time.Duration - fs, err := newDebugFileSystem() + fs, err := NewDebugFileSystem(false) require.NoError(t, err) features := *modules.GetModules().Features().ToProto() // safe to dereference because ToProto creates a struct and return a pointer to it @@ -3433,6 +3434,115 @@ func TestTokenGeneration(t *testing.T) { } } +func TestEndpointNotFoundHandling(t *testing.T) { + t.Parallel() + const username = "test-user@example.com" + // Allow user to create tokens. + roleTokenCRD, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{ + Allow: types.RoleConditions{ + Rules: []types.Rule{ + types.NewRule(types.KindToken, + []string{types.VerbCreate, types.VerbRead}), + }, + }, + }) + require.NoError(t, err) + + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, username, []types.Role{roleTokenCRD}) + + tt := []struct { + name string + endpoint string + shouldErr bool + }{ + { + name: "valid endpoint without v1 prefix", + endpoint: "webapi/token", + }, + { + name: "valid endpoint with v1 prefix", + endpoint: "v1/webapi/token", + }, + { + name: "valid endpoint with v2 prefix", + endpoint: "v2/webapi/token", + }, + { + name: "invalid double version prefixes", + endpoint: "v1/v2/webapi/token", + shouldErr: true, + }, + { + name: "route not matched version prefix", + endpoint: "v9999999/webapi/token", + shouldErr: true, + }, + { + name: "non api route with prefix", + endpoint: "v1/something/else", + shouldErr: true, + }, + { + name: "invalid triple version prefixes", + endpoint: "v1/v1/v1/webapi/token", + shouldErr: true, + }, + { + name: "invalid just prefix", + endpoint: "v1", + shouldErr: true, + }, + { + name: "invalid prefix", + endpoint: "v1s/webapi/token", + shouldErr: true, + }, + } + + for _, tc := range tt { + tc := tc + t.Run(tc.name, func(t *testing.T) { + re, err := pack.clt.PostJSON(context.Background(), fmt.Sprintf("%s/%s", proxy.web.URL, tc.endpoint), types.ProvisionTokenSpecV2{ + Roles: []types.SystemRole{types.RoleNode}, + JoinMethod: types.JoinMethodToken, + }) + + if tc.shouldErr { + require.True(t, trace.IsNotFound(err)) + + var rawObjMap map[string]*json.RawMessage + require.NoError(t, json.Unmarshal(re.Bytes(), &rawObjMap)) + + errMsg := struct{ Message string }{} + require.NoError(t, json.Unmarshal(*rawObjMap["error"], &errMsg)) + require.Equal(t, "path not found", errMsg.Message) + + fields := struct{ ProxyVersion httplib.ProxyVersion }{} + require.NoError(t, json.Unmarshal(*rawObjMap["fields"], &fields)) + require.Equal(t, teleport.Version, fields.ProxyVersion.String) + + ver, err := semver.NewVersion(teleport.Version) + require.NoError(t, err) + require.Equal(t, ver.Major, fields.ProxyVersion.Major) + require.Equal(t, ver.Minor, fields.ProxyVersion.Minor) + require.Equal(t, ver.Patch, fields.ProxyVersion.Patch) + require.Equal(t, string(ver.PreRelease), fields.ProxyVersion.PreRelease) + + return + } else { + require.NoError(t, err) + + var responseToken nodeJoinToken + err = json.Unmarshal(re.Bytes(), &responseToken) + require.NoError(t, err) + require.Equal(t, types.JoinMethodToken, responseToken.Method) + } + }) + } +} + func TestInstallDatabaseScriptGeneration(t *testing.T) { const username = "test-user@example.com" @@ -5015,7 +5125,7 @@ func TestDeleteMFA(t *testing.T) { jar, err := cookiejar.New(nil) require.NoError(t, err) opts := []roundtrip.ClientParam{roundtrip.BearerAuth(pack.session.Token), roundtrip.CookieJar(jar), roundtrip.HTTPClient(client.NewInsecureWebClient())} - rclt, err := roundtrip.NewClient(proxy.webURL.String(), teleport.WebAPIVersion, opts...) + rclt, err := roundtrip.NewClient(proxy.webURL.String(), "", opts...) require.NoError(t, err) clt := client.WebClient{Client: rclt} jar.SetCookies(&proxy.webURL, pack.cookies) @@ -8319,7 +8429,7 @@ func createProxy(ctx context.Context, t *testing.T, proxyID string, node *regula require.NoError(t, err) t.Cleanup(func() { require.NoError(t, proxyServer.Close()) }) - fs, err := newDebugFileSystem() + fs, err := NewDebugFileSystem(false) require.NoError(t, err) authID := state.IdentityID{ diff --git a/lib/web/apiserver_test_utils.go b/lib/web/apiserver_test_utils.go index d7fe5cd0bb3d7..9e6fff840b514 100644 --- a/lib/web/apiserver_test_utils.go +++ b/lib/web/apiserver_test_utils.go @@ -29,10 +29,14 @@ import ( ) // NewDebugFileSystem returns the HTTP file system implementation -func newDebugFileSystem() (http.FileSystem, error) { +func NewDebugFileSystem(isEnterprise bool) (http.FileSystem, error) { // If the location of the UI changes on disk then this will need to be updated. assetsPath := "../../webassets/teleport" + if isEnterprise { + assetsPath = "../../../webassets/teleport" + } + // Ensure we have the built assets available before continuing. for _, af := range []string{"index.html", "/app"} { _, err := os.Stat(filepath.Join(assetsPath, af)) diff --git a/lib/web/integrations_awsoidc.go b/lib/web/integrations_awsoidc.go index 844391b1523f9..27b9d580ecb84 100644 --- a/lib/web/integrations_awsoidc.go +++ b/lib/web/integrations_awsoidc.go @@ -743,6 +743,7 @@ func (h *Handler) awsOIDCConfigureEKSIAM(w http.ResponseWriter, r *http.Request, } // awsOIDCEnrollEKSClusters enroll EKS clusters by installing teleport-kube-agent Helm chart on them. +// v2 endpoint introduces "extraLabels" field. func (h *Handler) awsOIDCEnrollEKSClusters(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (any, error) { ctx := r.Context() diff --git a/lib/web/join_tokens.go b/lib/web/join_tokens.go index 033040a8545e0..df9896f5e1532 100644 --- a/lib/web/join_tokens.go +++ b/lib/web/join_tokens.go @@ -254,6 +254,8 @@ func (h *Handler) upsertTokenHandle(w http.ResponseWriter, r *http.Request, para return uiToken, nil } +// createTokenForDiscoveryHandle creates tokens used during guided discover flows. +// V2 endpoint processes "suggestedLabels" field. func (h *Handler) createTokenForDiscoveryHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { clt, err := ctx.GetClient() if err != nil { @@ -342,9 +344,10 @@ func (h *Handler) createTokenForDiscoveryHandle(w http.ResponseWriter, r *http.R // We create an ID and return it as part of the Token, so the UI can use this ID to query the Node that joined using this token // WebUI can then query the resources by this id and answer the question: // - Which Node joined the cluster from this token Y? - req.SuggestedLabels = types.Labels{ - types.InternalResourceIDLabel: apiutils.Strings{uuid.NewString()}, + if req.SuggestedLabels == nil { + req.SuggestedLabels = make(types.Labels) } + req.SuggestedLabels[types.InternalResourceIDLabel] = apiutils.Strings{uuid.NewString()} provisionToken, err := types.NewProvisionTokenFromSpec(tokenName, expires, req) if err != nil { diff --git a/lib/web/join_tokens_test.go b/lib/web/join_tokens_test.go index 08be47c4e448c..ba0b0be4ff9b1 100644 --- a/lib/web/join_tokens_test.go +++ b/lib/web/join_tokens_test.go @@ -43,6 +43,7 @@ import ( "github.com/gravitational/teleport/lib/fixtures" "github.com/gravitational/teleport/lib/modules" "github.com/gravitational/teleport/lib/services" + libui "github.com/gravitational/teleport/lib/ui" "github.com/gravitational/teleport/lib/web/ui" ) @@ -327,6 +328,71 @@ func TestDeleteToken(t *testing.T) { require.Empty(t, cmp.Diff(resp.Items, []ui.JoinToken{staticUIToken}, cmpopts.IgnoreFields(ui.JoinToken{}, "Content"))) } +func TestCreateTokenForDiscovery(t *testing.T) { + ctx := context.Background() + username := "test-user@example.com" + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, username, nil /* roles */) + + match := func(resp nodeJoinToken, userLabels types.Labels) { + if len(userLabels) > 0 { + require.Empty(t, cmp.Diff([]libui.Label{{Name: "env"}, {Name: "teleport.internal/resource-id"}}, resp.SuggestedLabels, cmpopts.SortSlices( + func(a, b libui.Label) bool { + return a.Name < b.Name + }, + ), cmpopts.IgnoreFields(libui.Label{}, "Value"))) + } else { + require.Empty(t, cmp.Diff([]libui.Label{{Name: "teleport.internal/resource-id"}}, resp.SuggestedLabels, cmpopts.IgnoreFields(libui.Label{}, "Value"))) + } + require.NotEmpty(t, resp.ID) + require.NotEmpty(t, resp.Expiry) + require.Equal(t, types.JoinMethodToken, resp.Method) + } + + tt := []struct { + name string + req types.ProvisionTokenSpecV2 + }{ + { + name: "with suggested labels", + req: types.ProvisionTokenSpecV2{ + Roles: []types.SystemRole{types.RoleNode}, + SuggestedLabels: types.Labels{"env": []string{"testing"}}, + }, + }, + { + name: "without suggested labels", + req: types.ProvisionTokenSpecV2{ + Roles: []types.SystemRole{types.RoleNode}, + SuggestedLabels: nil, + }, + }, + } + + for _, tc := range tt { + t.Run(fmt.Sprintf("v1 %s", tc.name), func(t *testing.T) { + endpointV1 := pack.clt.Endpoint("v1", "webapi", "token") + re, err := pack.clt.PostJSON(ctx, endpointV1, tc.req) + require.NoError(t, err) + + resp := nodeJoinToken{} + require.NoError(t, json.Unmarshal(re.Bytes(), &resp)) + match(resp, tc.req.SuggestedLabels) + }) + + t.Run(fmt.Sprintf("v2 %s", tc.name), func(t *testing.T) { + endpointV2 := pack.clt.Endpoint("v2", "webapi", "token") + re, err := pack.clt.PostJSON(ctx, endpointV2, tc.req) + require.NoError(t, err) + + resp := nodeJoinToken{} + require.NoError(t, json.Unmarshal(re.Bytes(), &resp)) + match(resp, tc.req.SuggestedLabels) + }) + } +} + func TestGenerateAzureTokenName(t *testing.T) { t.Parallel() rule1 := types.ProvisionTokenSpecV2Azure_Rule{ diff --git a/web/packages/build/vite/config.ts b/web/packages/build/vite/config.ts index 0d15db5fe3dbc..a429b6365aebd 100644 --- a/web/packages/build/vite/config.ts +++ b/web/packages/build/vite/config.ts @@ -105,14 +105,14 @@ export function createViteConfig( config.server.proxy = { // The format of the regex needs to assume that the slashes are escaped, for example: // \/v1\/webapi\/sites\/:site\/connect - [`^\\/v1\\/webapi\\/sites\\/${siteName}\\/connect`]: { + [`^\\/v[0-9]+\\/webapi\\/sites\\/${siteName}\\/connect`]: { target: `wss://${target}`, changeOrigin: false, secure: false, ws: true, }, // /webapi/sites/:site/desktops/:desktopName/connect - [`^\\/v1\\/webapi\\/sites\\/${siteName}\\/desktops\\/${siteName}\\/connect`]: + [`^\\/v[0-9]+\\/webapi\\/sites\\/${siteName}\\/desktops\\/${siteName}\\/connect`]: { target: `wss://${target}`, changeOrigin: false, @@ -120,31 +120,31 @@ export function createViteConfig( ws: true, }, // /webapi/sites/:site/kube/exec - [`^\\/v1\\/webapi\\/sites\\/${siteName}\\/kube/exec`]: { + [`^\\/v[0-9]+\\/webapi\\/sites\\/${siteName}\\/kube/exec`]: { target: `wss://${target}`, changeOrigin: false, secure: false, ws: true, }, // /webapi/sites/:site/desktopplayback/:sid - '^\\/v1\\/webapi\\/sites\\/(.*?)\\/desktopplayback\\/(.*?)': { + '^\\/v[0-9]+\\/webapi\\/sites\\/(.*?)\\/desktopplayback\\/(.*?)': { target: `wss://${target}`, changeOrigin: false, secure: false, ws: true, }, - '^\\/v1\\/webapi\\/assistant\\/(.*?)': { + '^\\/v[0-9]+\\/webapi\\/assistant\\/(.*?)': { target: `https://${target}`, changeOrigin: false, secure: false, }, - [`^\\/v1\\/webapi\\/sites\\/${siteName}\\/assistant`]: { + [`^\\/v[0-9]+\\/webapi\\/sites\\/${siteName}\\/assistant`]: { target: `wss://${target}`, changeOrigin: false, secure: false, ws: true, }, - '^\\/v1\\/webapi\\/command\\/(.*?)/execute': { + '^\\/v[0-9]+\\/webapi\\/command\\/(.*?)/execute': { target: `wss://${target}`, changeOrigin: false, secure: false, @@ -155,7 +155,7 @@ export function createViteConfig( changeOrigin: true, secure: false, }, - '/v1': { + '^\\/v[0-9]+': { target: `https://${target}`, changeOrigin: true, secure: false, diff --git a/web/packages/teleport/src/Discover/Database/DeployService/ManualDeploy/ManualDeploy.story.tsx b/web/packages/teleport/src/Discover/Database/DeployService/ManualDeploy/ManualDeploy.story.tsx index 4b11b3df65403..6cbf302b8b66c 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/ManualDeploy/ManualDeploy.story.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/ManualDeploy/ManualDeploy.story.tsx @@ -53,7 +53,9 @@ export const Init = () => { Init.parameters = { msw: { handlers: [ - http.post(cfg.api.joinTokenPath, () => HttpResponse.json(rawJoinToken)), + http.post(cfg.api.discoveryJoinToken.createV2, () => + HttpResponse.json(rawJoinToken) + ), ], }, }; @@ -74,7 +76,11 @@ export const InitWithLabels = () => { }; InitWithLabels.parameters = { msw: { - handlers: [http.post(cfg.api.joinTokenPath, () => HttpResponse.json({}))], + handlers: [ + http.post(cfg.api.discoveryJoinToken.createV2, () => + HttpResponse.json({}) + ), + ], }, }; diff --git a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/Dialogs.story.tsx b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/Dialogs.story.tsx index 76d6d67a0af96..e6dbe0bcb7635 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/Dialogs.story.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/Dialogs.story.tsx @@ -221,7 +221,7 @@ ManualHelmDialogStory.storyName = 'ManualHelmDialog'; ManualHelmDialogStory.parameters = { msw: { handlers: [ - http.post(cfg.api.joinTokenPath, () => { + http.post(cfg.api.discoveryJoinToken.createV2, () => { return HttpResponse.json({ id: 'token-id', suggestedLabels: [ diff --git a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.story.tsx b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.story.tsx index 9feab905372e7..77e4ff0d5f263 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.story.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.story.tsx @@ -69,7 +69,7 @@ export default { ], }; -const tokenHandler = http.post(cfg.api.joinTokenPath, () => { +const tokenHandler = http.post(cfg.api.discoveryJoinToken.createV2, () => { return HttpResponse.json({ id: 'token-id', suggestedLabels: [ diff --git a/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.story.tsx b/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.story.tsx index e112ed2c1ae89..911c013c81046 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.story.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.story.tsx @@ -60,7 +60,9 @@ export const Polling: StoryObj = { http.get(kubePathWithoutQuery, async () => { await delay('infinite'); }), - http.post(cfg.api.joinTokenPath, () => HttpResponse.json(rawJoinToken)), + http.post(cfg.api.discoveryJoinToken.createV2, () => + HttpResponse.json(rawJoinToken) + ), ], }, }, @@ -80,7 +82,9 @@ export const PollingSuccess: StoryObj = { http.get(kubePathWithoutQuery, () => { return HttpResponse.json({ items: [{}] }); }), - http.post(cfg.api.joinTokenPath, () => HttpResponse.json(rawJoinToken)), + http.post(cfg.api.discoveryJoinToken.createV2, () => + HttpResponse.json(rawJoinToken) + ), ], }, }, @@ -103,7 +107,9 @@ export const PollingError: StoryObj = { http.get(kubePathWithoutQuery, async () => { await delay('infinite'); }), - http.post(cfg.api.joinTokenPath, () => HttpResponse.json(rawJoinToken)), + http.post(cfg.api.discoveryJoinToken.createV2, () => + HttpResponse.json(rawJoinToken) + ), ], }, }, @@ -120,7 +126,7 @@ export const Processing: StoryObj = { parameters: { msw: { handlers: [ - http.post(cfg.api.joinTokenPath, async () => { + http.post(cfg.api.discoveryJoinToken.createV2, async () => { await delay('infinite'); }), ], @@ -139,7 +145,7 @@ export const Failed: StoryObj = { parameters: { msw: { handlers: [ - http.post(cfg.getJoinTokenUrl(), () => + http.post(cfg.api.discoveryJoinToken.createV2, () => HttpResponse.json( { error: { message: 'Whoops, something went wrong.' }, diff --git a/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.story.tsx b/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.story.tsx index 4deb03187e722..08f5d55f73234 100644 --- a/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.story.tsx +++ b/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.story.tsx @@ -66,7 +66,7 @@ export const SuccessCloud = () => { SuccessCloud.parameters = { msw: { handlers: [ - http.post(cfg.api.joinTokenPath, () => + http.post(cfg.api.discoveryJoinToken.createV2, () => HttpResponse.json({ id: 'token-id' }) ), http.post(cfg.api.discoveryConfigPath, () => @@ -90,7 +90,7 @@ export const SuccessSelfHosted = () => ( SuccessSelfHosted.parameters = { msw: { handlers: [ - http.post(cfg.api.joinTokenPath, () => + http.post(cfg.api.discoveryJoinToken.createV2, () => HttpResponse.json({ id: 'token-id' }) ), http.post(cfg.api.discoveryConfigPath, () => @@ -107,7 +107,7 @@ export const Loading = () => { Loading.parameters = { msw: { handlers: [ - http.post(cfg.api.joinTokenPath, () => + http.post(cfg.api.discoveryJoinToken.createV2, () => HttpResponse.json({ id: 'token-id' }) ), http.post(cfg.api.discoveryConfigPath, () => delay('infinite')), @@ -122,7 +122,7 @@ export const Failed = () => { Failed.parameters = { msw: { handlers: [ - http.post(cfg.api.joinTokenPath, () => + http.post(cfg.api.discoveryJoinToken.createV2, () => HttpResponse.json({ id: 'token-id' }) ), http.post(cfg.api.discoveryConfigPath, () => diff --git a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx b/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx index 02f2b8e488127..7dad3e0ec67de 100644 --- a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx +++ b/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx @@ -63,7 +63,7 @@ export const Polling: StoryObj = { http.get(nodesPathWithoutQuery, () => { return delay('infinite'); }), - http.post(cfg.api.joinTokenPath, () => { + http.post(cfg.api.discoveryJoinToken.createV2, () => { return HttpResponse.json(joinToken); }), ], @@ -86,7 +86,7 @@ export const PollingSuccess: StoryObj = { http.get(nodesPathWithoutQuery, () => { return HttpResponse.json({ items: [{}] }); }), - http.post(cfg.api.joinTokenPath, () => { + http.post(cfg.api.discoveryJoinToken.createV2, () => { return HttpResponse.json(joinToken); }), ], @@ -111,7 +111,7 @@ export const PollingError: StoryObj = { http.get(nodesPathWithoutQuery, () => { return delay('infinite'); }), - http.post(cfg.api.joinTokenPath, () => { + http.post(cfg.api.discoveryJoinToken.createV2, () => { return HttpResponse.json(joinToken); }), ], @@ -130,7 +130,7 @@ export const Processing: StoryObj = { parameters: { msw: { handlers: [ - http.post(cfg.api.joinTokenPath, () => { + http.post(cfg.api.discoveryJoinToken.createV2, () => { return delay('infinite'); }), ], @@ -149,7 +149,7 @@ export const Failed: StoryObj = { parameters: { msw: { handlers: [ - http.post(cfg.api.joinTokenPath, () => { + http.post(cfg.api.discoveryJoinToken.createV2, () => { return HttpResponse.json( { error: { message: 'Whoops, something went wrong.' }, diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.test.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.test.tsx index 882bf66d2a59b..142e680f8c8ec 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.test.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.test.tsx @@ -125,7 +125,10 @@ test('generate command', async () => { // Test create is still called with 404 ping error. jest.clearAllMocks(); - let error = new ApiError('', { status: 404 } as Response); + let error = new ApiError({ + message: '', + response: { status: 404 } as Response, + }); spyPing = jest .spyOn(integrationService, 'pingAwsOidcIntegration') .mockRejectedValue(error); @@ -136,7 +139,7 @@ test('generate command', async () => { // Test create isn't called with non 404 error jest.clearAllMocks(); - error = new ApiError('', { status: 400 } as Response); + error = new ApiError({ message: '', response: { status: 400 } as Response }); spyPing = jest .spyOn(integrationService, 'pingAwsOidcIntegration') .mockRejectedValue(error); diff --git a/web/packages/teleport/src/components/Authenticated/Authenticated.test.tsx b/web/packages/teleport/src/components/Authenticated/Authenticated.test.tsx index 64e24fd97b312..00478177ab623 100644 --- a/web/packages/teleport/src/components/Authenticated/Authenticated.test.tsx +++ b/web/packages/teleport/src/components/Authenticated/Authenticated.test.tsx @@ -69,9 +69,12 @@ describe('session', () => { }); test('valid session and invalid cookie', async () => { - const mockForbiddenError = new ApiError('some error', { - status: 403, - } as Response); + const mockForbiddenError = new ApiError({ + message: 'some error', + response: { + status: 403, + } as Response, + }); jest .spyOn(session, 'validateCookieAndSession') diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index d0daeb59e6c88..201a8cbac0932 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -284,7 +284,11 @@ const cfg = { trustedClustersPath: '/v1/webapi/trustedcluster/:name?', connectMyComputerLoginsPath: '/v1/webapi/connectmycomputer/logins', - joinTokenPath: '/v1/webapi/token', + discoveryJoinToken: { + // TODO(kimlisa): DELETE IN 19.0 - replaced by /v2/webapi/token + create: '/v1/webapi/token', + createV2: '/v2/webapi/token', + }, joinTokenYamlPath: '/v1/webapi/tokens/yaml', joinTokensPath: '/v1/webapi/tokens', dbScriptPath: '/scripts/:token/install-database.sh', @@ -367,8 +371,14 @@ const cfg = { eksClustersListPath: '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/eksclusters', - eksEnrollClustersPath: - '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/enrolleksclusters', + + eks: { + // TODO(kimlisa): DELETE IN 19.0 - replaced by /v2/webapi/sites/:clusterId/integrations/aws-oidc/:name/enrolleksclusters + enroll: + '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/enrolleksclusters', + enrollV2: + '/v2/webapi/sites/:clusterId/integrations/aws-oidc/:name/enrolleksclusters', + }, ec2InstancesListPath: '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/ec2', @@ -574,10 +584,6 @@ const cfg = { return cfg.api.joinTokensPath; }, - getJoinTokenUrl() { - return cfg.api.joinTokenPath; - }, - getJoinTokenYamlUrl() { return cfg.api.joinTokenYamlPath; }, @@ -1083,7 +1089,16 @@ const cfg = { getEnrollEksClusterUrl(integrationName: string): string { const clusterId = cfg.proxyCluster; - return generatePath(cfg.api.eksEnrollClustersPath, { + return generatePath(cfg.api.eks.enroll, { + clusterId, + name: integrationName, + }); + }, + + getEnrollEksClusterUrlV2(integrationName: string): string { + const clusterId = cfg.proxyCluster; + + return generatePath(cfg.api.eks.enrollV2, { clusterId, name: integrationName, }); diff --git a/web/packages/teleport/src/services/agents/make.ts b/web/packages/teleport/src/services/agents/make.ts index 4bc8afe8186fc..8cb27dbed5191 100644 --- a/web/packages/teleport/src/services/agents/make.ts +++ b/web/packages/teleport/src/services/agents/make.ts @@ -53,7 +53,7 @@ function makeTraces(traces: any): ConnectionDiagnosticTrace[] { export function makeLabelMapOfStrArrs(labels: ResourceLabel[] = []) { const m: Record = {}; - labels.forEach(label => { + labels?.forEach(label => { if (!m[label.name]) { m[label.name] = []; } diff --git a/web/packages/teleport/src/services/api/api.ts b/web/packages/teleport/src/services/api/api.ts index 5b19aef0bf580..9c75858a05e58 100644 --- a/web/packages/teleport/src/services/api/api.ts +++ b/web/packages/teleport/src/services/api/api.ts @@ -23,7 +23,7 @@ import websession from 'teleport/services/websession'; import { MfaChallengeResponse } from '../mfa'; import { storageService } from '../storageService'; -import parseError, { ApiError } from './parseError'; +import parseError, { ApiError, parseProxyVersion } from './parseError'; export const MFA_HEADER = 'Teleport-Mfa-Response'; @@ -148,10 +148,11 @@ const api = { try { json = await response.json(); } catch (err) { + // error reading JSON const message = response.ok ? err.message : `${response.status} - ${response.url}`; - throw new ApiError(message, response, { cause: err }); + throw new ApiError({ message, response, opts: { cause: err } }); } if (response.ok) { @@ -176,7 +177,12 @@ const api = { ); const shouldRetry = isAdminActionMfaError && !mfaResponse; if (!shouldRetry) { - throw new ApiError(parseError(json), response, undefined, json.messages); + throw new ApiError({ + message: parseError(json), + response, + proxyVersion: parseProxyVersion(json), + messages: json.messages, + }); } let mfaResponseForRetry; diff --git a/web/packages/teleport/src/services/api/parseError.ts b/web/packages/teleport/src/services/api/parseError.ts index 3ef3e43190bbb..cdf326e00a222 100644 --- a/web/packages/teleport/src/services/api/parseError.ts +++ b/web/packages/teleport/src/services/api/parseError.ts @@ -16,6 +16,42 @@ * along with this program. If not, see . */ +/** + * The version of the proxy where the error occurred. + * + * Currently, the proxy version field is only returned + * with api routes "not found" error. + * + * Used to determine outdated proxies. + * + * This response was introduced in v17.2.0. + */ +interface ProxyVersion { + major: number; + minor: number; + patch: number; + /** + * defined if version is not for production eg: + * the prerelease value for version 17.0.0-dev, is "dev" + */ + preRelease: string; + /** + * full version in string eg: "17.0.0-dev" + */ + string: string; +} + +interface ApiErrorConstructor { + /** + * message is the main error, usually the "cause" of the error. + */ + message: string; + response: Response; + proxyVersion?: ProxyVersion; + opts?: ErrorOptions; + messages?: string[]; +} + export default function parseError(json) { let msg = ''; @@ -29,6 +65,10 @@ export default function parseError(json) { return msg; } +export function parseProxyVersion(json): ProxyVersion | undefined { + return json?.fields?.proxyVersion; +} + export class ApiError extends Error { response: Response; /** @@ -41,17 +81,23 @@ export class ApiError extends Error { */ messages: string[]; - constructor( - message: string, - response: Response, - opts?: ErrorOptions, - messages?: string[] - ) { - // message is the main error, usually the "cause" of the error. + /** + * Only defined with api routes "not found" error. + */ + proxyVersion?: ProxyVersion; + + constructor({ + message, + response, + proxyVersion, + opts, + messages, + }: ApiErrorConstructor) { message = message || 'Unknown error'; super(message, opts); this.response = response; this.name = 'ApiError'; this.messages = messages || []; + this.proxyVersion = proxyVersion; } } diff --git a/web/packages/teleport/src/services/integrations/integrations.test.ts b/web/packages/teleport/src/services/integrations/integrations.test.ts index 1d636f068221f..ccab940bbf8af 100644 --- a/web/packages/teleport/src/services/integrations/integrations.test.ts +++ b/web/packages/teleport/src/services/integrations/integrations.test.ts @@ -22,6 +22,10 @@ import api from 'teleport/services/api'; import { integrationService } from './integrations'; import { IntegrationAudience, IntegrationStatusCode } from './types'; +beforeEach(() => { + jest.resetAllMocks(); +}); + test('fetch a single integration: fetchIntegration()', async () => { // test a valid response jest.spyOn(api, 'get').mockResolvedValue(awsOidcIntegration); @@ -196,6 +200,50 @@ test('fetchAwsDatabases response', async () => { }); }); +test('enrollEksClusters without labels calls v1', async () => { + jest.spyOn(api, 'post').mockResolvedValue({}); + + await integrationService.enrollEksClusters('integration', { + region: 'us-east-1', + enableAppDiscovery: false, + clusterNames: ['cluster'], + }); + + expect(api.post).toHaveBeenCalledWith( + cfg.getEnrollEksClusterUrl('integration'), + { + clusterNames: ['cluster'], + enableAppDiscovery: false, + region: 'us-east-1', + }, + null, + undefined + ); +}); + +test('enrollEksClusters with labels calls v2', async () => { + jest.spyOn(api, 'post').mockResolvedValue({}); + + await integrationService.enrollEksClusters('integration', { + region: 'us-east-1', + enableAppDiscovery: false, + clusterNames: ['cluster'], + extraLabels: [{ name: 'env', value: 'staging' }], + }); + + expect(api.post).toHaveBeenCalledWith( + cfg.getEnrollEksClusterUrlV2('integration'), + { + clusterNames: ['cluster'], + enableAppDiscovery: false, + region: 'us-east-1', + extraLabels: [{ name: 'env', value: 'staging' }], + }, + null, + undefined + ); +}); + describe('fetchAwsDatabases() request body formatting', () => { test.each` protocol | expectedEngines | expectedRdsType diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index 7b1ffa0b1724d..e2eef4a21c58d 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -23,6 +23,7 @@ import { App } from '../apps'; import makeApp from '../apps/makeApps'; import auth, { MfaChallengeScope } from '../auth/auth'; import makeNode from '../nodes/makeNode'; +import { withUnsupportedLabelFeatureErrorConversion } from '../version/unsupported'; import { AwsDatabaseVpcsResponse, AwsOidcDeployDatabaseServicesRequest, @@ -319,11 +320,26 @@ export const integrationService = { ): Promise { const mfaResponse = await auth.getMfaChallengeResponseForAdminAction(true); - return api.post( - cfg.getEnrollEksClusterUrl(integrationName), - req, - null, - mfaResponse + // TODO(kimlisa): DELETE IN 19.0 - replaced by v2 endpoint. + if (!req.extraLabels?.length) { + return api.post( + cfg.getEnrollEksClusterUrl(integrationName), + req, + null, + mfaResponse + ); + } + + return ( + api + .post( + cfg.getEnrollEksClusterUrlV2(integrationName), + req, + null, + mfaResponse + ) + // TODO(kimlisa): DELETE IN 19.0 + .catch(withUnsupportedLabelFeatureErrorConversion) ); }, diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index ff8f4347b985c..e409136ae2e9e 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -18,6 +18,7 @@ import { Label } from 'teleport/types'; +import { ResourceLabel } from '../agents'; import { Node } from '../nodes'; /** @@ -539,6 +540,11 @@ export type EnrollEksClustersRequest = { region: string; enableAppDiscovery: boolean; clusterNames: string[]; + /** + * User provided labels. + * Only supported with V2 endpoint + */ + extraLabels?: ResourceLabel[]; }; export type EnrollEksClustersResponse = { diff --git a/web/packages/teleport/src/services/joinToken/joinToken.test.ts b/web/packages/teleport/src/services/joinToken/joinToken.test.ts index 1f941345c1006..6a45afe0824f0 100644 --- a/web/packages/teleport/src/services/joinToken/joinToken.test.ts +++ b/web/packages/teleport/src/services/joinToken/joinToken.test.ts @@ -22,6 +22,10 @@ import api from 'teleport/services/api'; import JoinTokenService from './joinToken'; import type { JoinTokenRequest } from './types'; +beforeEach(() => { + jest.resetAllMocks(); +}); + test('fetchJoinToken with an empty request properly sets defaults', () => { const svc = new JoinTokenService(); jest.spyOn(api, 'post').mockResolvedValue(null); @@ -29,7 +33,7 @@ test('fetchJoinToken with an empty request properly sets defaults', () => { // Test with all empty fields. svc.fetchJoinToken({} as any); expect(api.post).toHaveBeenCalledWith( - cfg.getJoinTokenUrl(), + cfg.api.discoveryJoinToken.create, { roles: undefined, join_method: 'token', @@ -52,7 +56,7 @@ test('fetchJoinToken request fields are set as requested', () => { }; svc.fetchJoinToken(mock); expect(api.post).toHaveBeenCalledWith( - cfg.getJoinTokenUrl(), + cfg.api.discoveryJoinToken.create, { roles: ['Node'], join_method: 'iam', @@ -62,3 +66,23 @@ test('fetchJoinToken request fields are set as requested', () => { null ); }); + +test('fetchJoinToken with labels calls v2 endpoint', () => { + const svc = new JoinTokenService(); + jest.spyOn(api, 'post').mockResolvedValue(null); + + const mock: JoinTokenRequest = { + suggestedLabels: [{ name: 'env', value: 'testing' }], + }; + svc.fetchJoinToken(mock); + expect(api.post).toHaveBeenCalledWith( + cfg.api.discoveryJoinToken.createV2, + { + suggested_labels: { env: ['testing'] }, + suggested_agent_matcher_labels: {}, + join_method: 'token', + allow: [], + }, + null + ); +}); diff --git a/web/packages/teleport/src/services/joinToken/joinToken.ts b/web/packages/teleport/src/services/joinToken/joinToken.ts index fe564b4440dae..66d6f0b20894f 100644 --- a/web/packages/teleport/src/services/joinToken/joinToken.ts +++ b/web/packages/teleport/src/services/joinToken/joinToken.ts @@ -20,6 +20,7 @@ import cfg from 'teleport/config'; import api from 'teleport/services/api'; import { makeLabelMapOfStrArrs } from '../agents/make'; +import { withUnsupportedLabelFeatureErrorConversion } from '../version/unsupported'; import makeJoinToken from './makeJoinToken'; import { JoinRule, JoinToken, JoinTokenRequest } from './types'; @@ -31,20 +32,43 @@ class JoinTokenService { req: JoinTokenRequest, signal: AbortSignal = null ): Promise { - return api - .post( - cfg.getJoinTokenUrl(), - { - roles: req.roles, - join_method: req.method || 'token', - allow: makeAllowField(req.rules || []), - suggested_agent_matcher_labels: makeLabelMapOfStrArrs( - req.suggestedAgentMatcherLabels - ), - }, - signal - ) - .then(makeJoinToken); + // TODO(kimlisa): DELETE IN 19.0 - replaced by v2 endpoint. + if (!req.suggestedLabels?.length) { + return api + .post( + cfg.api.discoveryJoinToken.create, + { + roles: req.roles, + join_method: req.method || 'token', + allow: makeAllowField(req.rules || []), + suggested_agent_matcher_labels: makeLabelMapOfStrArrs( + req.suggestedAgentMatcherLabels + ), + }, + signal + ) + .then(makeJoinToken); + } + + return ( + api + .post( + cfg.api.discoveryJoinToken.createV2, + { + roles: req.roles, + join_method: req.method || 'token', + allow: makeAllowField(req.rules || []), + suggested_agent_matcher_labels: makeLabelMapOfStrArrs( + req.suggestedAgentMatcherLabels + ), + suggested_labels: makeLabelMapOfStrArrs(req.suggestedLabels), + }, + signal + ) + .then(makeJoinToken) + // TODO(kimlisa): DELETE IN 19.0 + .catch(withUnsupportedLabelFeatureErrorConversion) + ); } upsertJoinTokenYAML( diff --git a/web/packages/teleport/src/services/joinToken/types.ts b/web/packages/teleport/src/services/joinToken/types.ts index a36c1a975d1bd..3daa8f3322a70 100644 --- a/web/packages/teleport/src/services/joinToken/types.ts +++ b/web/packages/teleport/src/services/joinToken/types.ts @@ -138,4 +138,13 @@ export type JoinTokenRequest = { method?: JoinMethod; // content is the yaml content of the joinToken to be created content?: string; + /** + * User provided labels. + * SuggestedLabels is a set of labels that resources should set when using this token to enroll + * themselves in the cluster. + * Currently, only node-join scripts create a configuration according to the suggestion. + * + * Only supported with V2 endpoint. + */ + suggestedLabels?: ResourceLabel[]; }; diff --git a/web/packages/teleport/src/services/version/unsupported.ts b/web/packages/teleport/src/services/version/unsupported.ts new file mode 100644 index 0000000000000..df21c804c8df4 --- /dev/null +++ b/web/packages/teleport/src/services/version/unsupported.ts @@ -0,0 +1,33 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { ApiError } from '../api/parseError'; + +export function withUnsupportedLabelFeatureErrorConversion( + err: unknown +): never { + if (err instanceof ApiError && err.response.status === 404) { + throw new Error( + 'We could not complete your request. ' + + 'Your proxy may be behind the minimum required version ' + + `(v17.2.0) to support adding resource labels. ` + + 'Ensure all proxies are upgraded or remove labels and try again.' + ); + } + throw err; +}