From 73b2a30187acc2f272c40b6f7fbf33e493b4181e Mon Sep 17 00:00:00 2001 From: Erik Tate Date: Tue, 7 Oct 2025 19:40:21 -0400 Subject: [PATCH] adding scoped tokens subcommands --- api/client/client.go | 25 ++ lib/auth/scopes/joining/service.go | 4 + lib/join/token/scoped.go | 108 ++++++ tool/tctl/common/cmds.go | 1 + tool/tctl/common/helpers_test.go | 10 + tool/tctl/common/scoped_command.go | 60 ++++ tool/tctl/common/scoped_token_command.go | 310 ++++++++++++++++++ tool/tctl/common/scoped_token_command_test.go | 125 +++++++ tool/tctl/common/token_command.go | 272 ++++++++------- 9 files changed, 793 insertions(+), 122 deletions(-) create mode 100644 lib/join/token/scoped.go create mode 100644 tool/tctl/common/scoped_command.go create mode 100644 tool/tctl/common/scoped_token_command.go create mode 100644 tool/tctl/common/scoped_token_command_test.go diff --git a/api/client/client.go b/api/client/client.go index d2e2f59b463d7..fdda50ecfa4ca 100644 --- a/api/client/client.go +++ b/api/client/client.go @@ -95,6 +95,7 @@ import ( resourceusagepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/resourceusage/v1" samlidppb "github.com/gravitational/teleport/api/gen/proto/go/teleport/samlidp/v1" scopedaccessv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/access/v1" + joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1" secreportsv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/secreports/v1" stableunixusersv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/stableunixusers/v1" summarizerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/summarizer/v1" @@ -134,6 +135,7 @@ type AuthServiceClient struct { userpreferencespb.UserPreferencesServiceClient notificationsv1pb.NotificationServiceClient recordingencryptionv1pb.RecordingEncryptionServiceClient + joiningv1.ScopedJoiningServiceClient } // Client is a gRPC Client that connects to a Teleport Auth server either @@ -547,6 +549,7 @@ func (c *Client) dialGRPC(ctx context.Context, addr string) error { UserPreferencesServiceClient: userpreferencespb.NewUserPreferencesServiceClient(c.conn), NotificationServiceClient: notificationsv1pb.NewNotificationServiceClient(c.conn), RecordingEncryptionServiceClient: recordingencryptionv1pb.NewRecordingEncryptionServiceClient(c.conn), + ScopedJoiningServiceClient: joiningv1.NewScopedJoiningServiceClient(c.conn), } c.JoinServiceClient = NewJoinServiceClient(proto.NewJoinServiceClient(c.conn)) @@ -5811,3 +5814,25 @@ func (c *Client) ValidateTrustedCluster( } return resp, nil } + +// ListScopedTokens fetches pages of scoped tokens. +func (c *Client) ListScopedTokens(ctx context.Context, req *joiningv1.ListScopedTokensRequest) (*joiningv1.ListScopedTokensResponse, error) { + res, err := c.grpc.ListScopedTokens(ctx, req) + return res, trace.Wrap(err) +} + +// DeleteScopedToken deletes an existing scoped token. +func (c *Client) DeleteScopedToken(ctx context.Context, name string) error { + _, err := c.grpc.DeleteScopedToken(ctx, &joiningv1.DeleteScopedTokenRequest{ + Name: name, + }) + return trace.Wrap(err) +} + +// CreateScopedToken creates a new scoped token. +func (c *Client) CreateScopedToken(ctx context.Context, token *joiningv1.ScopedToken) (*joiningv1.ScopedToken, error) { + res, err := c.grpc.CreateScopedToken(ctx, &joiningv1.CreateScopedTokenRequest{ + Token: token, + }) + return res.GetToken(), trace.Wrap(err) +} diff --git a/lib/auth/scopes/joining/service.go b/lib/auth/scopes/joining/service.go index 8de0e110b6772..fddf9a29a7fe9 100644 --- a/lib/auth/scopes/joining/service.go +++ b/lib/auth/scopes/joining/service.go @@ -94,6 +94,10 @@ func (s *Server) CreateScopedToken(ctx context.Context, req *scopedjoiningv1.Cre token.Metadata.Name = name } + if token.GetSpec() != nil && token.GetSpec().GetJoinMethod() == "" { + token.Spec.JoinMethod = string(types.JoinMethodToken) + } + res, err := s.backend.CreateScopedToken(ctx, req) return res, trace.Wrap(err) } diff --git a/lib/join/token/scoped.go b/lib/join/token/scoped.go new file mode 100644 index 0000000000000..b04669ceecbc5 --- /dev/null +++ b/lib/join/token/scoped.go @@ -0,0 +1,108 @@ +/* + * Teleport + * Copyright (C) 2025 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 . + */ + +package token + +import ( + "strings" + "time" + + "github.com/gravitational/trace" + + joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1" + "github.com/gravitational/teleport/api/types" +) + +// Scoped wraps a [joiningv1.ScopedToken] such that it can be used to provision +// resources. +type Scoped struct { + token *joiningv1.ScopedToken + joinMethod types.JoinMethod + roles types.SystemRoles +} + +// NewScoped returns the wrapped version of the given [joiningv1.ScopedToken]. +// It will return an error if the configured join method is not a valid +// [types.JoinMethod] or if any of the configured roles are not a valid +// [types.SystemRole]. The validated join method and roles are cached on the +// [Scoped] wrapper itself so they can be read without repeating validation. +func NewScoped(token *joiningv1.ScopedToken) (*Scoped, error) { + joinMethod := types.JoinMethod(token.GetSpec().GetJoinMethod()) + if err := types.ValidateJoinMethod(joinMethod); err != nil { + return nil, trace.Wrap(err) + } + + roles, err := types.NewTeleportRoles(token.GetSpec().GetRoles()) + if err != nil { + return nil, trace.Wrap(err) + } + + return &Scoped{token: token, joinMethod: joinMethod, roles: roles}, nil +} + +// GetName returns the name of a [joiningv1.ScopedToken]. +func (s *Scoped) GetName() string { + return s.token.GetMetadata().GetName() +} + +// GetJoinMethod returns the cached [types.JoinMethod] generated when the +// [joiningv1.ScopedToken] was wrapped. +func (s *Scoped) GetJoinMethod() types.JoinMethod { + return s.joinMethod +} + +// GetRoles returns the cached [types.SystemRoles] generated when the +// [joiningv1.ScopedToken] was wrapped. +func (s *Scoped) GetRoles() types.SystemRoles { + return s.roles +} + +// GetSafeName returns the name of the scoped token, sanitized appropriately +// for join methods where the name is secret. This should be used when logging +// the token name. +func (s *Scoped) GetSafeName() string { + return GetSafeScopedTokenName(s.token) +} + +// Expiry returns the [time.Time] representing when the wrapped +// [joiningv1.ScopedToken] will expire. +func (s *Scoped) Expiry() time.Time { + return s.token.GetMetadata().GetExpires().AsTime() +} + +// GetSafeScopedTokenName returns the name of the scoped token, sanitized +// appropriately for join methods where the name is secret. This should be used +// when logging the token name. +func GetSafeScopedTokenName(token *joiningv1.ScopedToken) string { + name := token.GetMetadata().GetName() + if types.JoinMethod(token.GetSpec().GetJoinMethod()) != types.JoinMethodToken { + return name + } + + // If the token name is short, we just blank the whole thing. + if len(name) < 16 { + return strings.Repeat("*", len(name)) + } + + // If the token name is longer, we can show the last 25% of it to help + // the operator identify it. + hiddenBefore := int(0.75 * float64(len(name))) + name = name[hiddenBefore:] + name = strings.Repeat("*", hiddenBefore) + name + return name +} diff --git a/tool/tctl/common/cmds.go b/tool/tctl/common/cmds.go index 21cf58600a901..4621c87aaddf6 100644 --- a/tool/tctl/common/cmds.go +++ b/tool/tctl/common/cmds.go @@ -73,5 +73,6 @@ func Commands() []CLICommand { &stableunixusers.Command{}, &decision.Command{}, &BoundKeypairCommand{}, + &ScopedCommand{}, } } diff --git a/tool/tctl/common/helpers_test.go b/tool/tctl/common/helpers_test.go index 8866604027cb7..bc734f9ff7f2d 100644 --- a/tool/tctl/common/helpers_test.go +++ b/tool/tctl/common/helpers_test.go @@ -120,6 +120,16 @@ func runTokensCommand(t *testing.T, client *authclient.Client, args []string) (* return &stdoutBuff, runCommand(t, client, command, args) } +func runScopedCommand(t *testing.T, client *authclient.Client, args []string) (*bytes.Buffer, error) { + var stdoutBuff bytes.Buffer + command := &ScopedCommand{ + Stdout: &stdoutBuff, + } + + args = append([]string{"scoped"}, args...) + return &stdoutBuff, runCommand(t, client, command, args) +} + func runUserCommand(t *testing.T, client *authclient.Client, args []string) error { command := &UserCommand{} args = append([]string{"users"}, args...) diff --git a/tool/tctl/common/scoped_command.go b/tool/tctl/common/scoped_command.go new file mode 100644 index 0000000000000..4d5d0983530f6 --- /dev/null +++ b/tool/tctl/common/scoped_command.go @@ -0,0 +1,60 @@ +/* + * Teleport + * Copyright (C) 2025 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 . + */ + +package common + +import ( + "context" + "io" + "os" + + "github.com/alecthomas/kingpin/v2" + + "github.com/gravitational/teleport/lib/service/servicecfg" + commonclient "github.com/gravitational/teleport/tool/tctl/common/client" + tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config" +) + +// ScopedCommand implements scoped variants of tctl command groups, such as +// `tctl scoped tokens`. +type ScopedCommand struct { + config *servicecfg.Config + tokens *ScopedTokensCommand + Stdout io.Writer +} + +// Initialize allows ScopedCommand to plug itself into the CLI parser +func (c *ScopedCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) { + c.config = config + scoped := app.Command("scoped", "Run a subcommand using scoped auth") + + if c.Stdout == nil { + c.Stdout = os.Stdout + } + + c.tokens = &ScopedTokensCommand{ + Stdout: c.Stdout, + } + + c.tokens.Initialize(scoped, config) +} + +// TryRun takes the CLI command as an argument (like "scoped tokens") and executes it. +func (c *ScopedCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (match bool, err error) { + return c.tokens.TryRun(ctx, cmd, clientFunc) +} diff --git a/tool/tctl/common/scoped_token_command.go b/tool/tctl/common/scoped_token_command.go new file mode 100644 index 0000000000000..8564574d0edfa --- /dev/null +++ b/tool/tctl/common/scoped_token_command.go @@ -0,0 +1,310 @@ +/* + * Teleport + * Copyright (C) 2025 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 . + */ + +package common + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "slices" + "strings" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + "google.golang.org/protobuf/types/known/timestamppb" + "gopkg.in/yaml.v3" + + "github.com/gravitational/teleport" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + joiningv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/joining/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/clientutils" + "github.com/gravitational/teleport/lib/asciitable" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/itertools/stream" + "github.com/gravitational/teleport/lib/join/token" + "github.com/gravitational/teleport/lib/service/servicecfg" + "github.com/gravitational/teleport/lib/utils" + commonclient "github.com/gravitational/teleport/tool/tctl/common/client" +) + +// ScopedTokensCommand implements `tctl scoped tokens` group of commands +type ScopedTokensCommand struct { + config *servicecfg.Config + + withSecrets bool + + // format is the output format, e.g. text or json + format string + + // tokenType is the type of token. For example, "node". + tokenType string + + // Value is the value of the token. Can be used to either act on a + // token (for example, delete a token) or used to create a token with a + // specific value. + value string + + // assignedScope allows for filtering tokens by the scope they assign + assignedScope string + + // tokenScope allows for filtering tokens by the scope they belong to + tokenScope string + + // ttl is how long the token will live for. + ttl time.Duration + + // tokenAdd is used to add a token. + tokenAdd *kingpin.CmdClause + + // tokenDel is used to delete a token. + tokenDel *kingpin.CmdClause + + // tokenList is used to view all tokens that Teleport knows about. + tokenList *kingpin.CmdClause + + // Stdout allows to switch the standard output source. Used in tests. + Stdout io.Writer +} + +// Initialize allows TokenCommand to plug itself into the CLI parser +func (c *ScopedTokensCommand) Initialize(scopedCmd *kingpin.CmdClause, config *servicecfg.Config) { + c.config = config + + tokens := scopedCmd.Command("tokens", "List or revoke scoped invitation tokens") + + formats := []string{teleport.Text, teleport.JSON, teleport.YAML} + + // tctl scoped tokens add ..." + c.tokenAdd = tokens.Command("add", "Create a scoped invitation token.") + c.tokenAdd.Flag("type", "Type(s) of token to add, e.g. --type=node").Required().StringVar(&c.tokenType) + c.tokenAdd.Flag("value", "Override the default random generated token with a specified value").StringVar(&c.value) + c.tokenAdd.Flag("ttl", fmt.Sprintf("Set expiration time for token, default is %v minutes", + int(defaults.ProvisioningTokenTTL/time.Minute))). + Default(fmt.Sprintf("%v", defaults.ProvisioningTokenTTL)). + DurationVar(&c.ttl) + c.tokenAdd.Flag("format", "Output format, 'text', 'json', or 'yaml'").EnumVar(&c.format, formats...) + c.tokenAdd.Flag("assign-scope", "Scope that should be applied to resources provisioned by this token").StringVar(&c.assignedScope) + c.tokenAdd.Flag("scope", "Scope assigned to the token itself").StringVar(&c.tokenScope) + + // "tctl scoped tokens rm ..." + c.tokenDel = tokens.Command("rm", "Delete/revoke a scoped invitation token.").Alias("del") + c.tokenDel.Arg("token", "Token to delete").StringVar(&c.value) + + // "tctl scoped tokens ls" + c.tokenList = tokens.Command("ls", "List invitation tokens.") + c.tokenList.Flag("format", "Output format, 'text', 'json' or 'yaml'").EnumVar(&c.format, formats...) + c.tokenList.Flag("with-secrets", "Do not redact join tokens").BoolVar(&c.withSecrets) + + if c.Stdout == nil { + c.Stdout = os.Stdout + } +} + +// TryRun attempts to run subcommands like like "scoped tokens ls". +func (c *ScopedTokensCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (match bool, err error) { + var commandFunc func(ctx context.Context, client *authclient.Client) error + switch cmd { + case c.tokenAdd.FullCommand(): + commandFunc = c.Add + case c.tokenDel.FullCommand(): + commandFunc = c.Del + case c.tokenList.FullCommand(): + commandFunc = c.List + default: + return false, nil + } + client, closeFn, err := clientFunc(ctx) + if err != nil { + return false, trace.Wrap(err) + } + err = commandFunc(ctx, client) + closeFn(ctx) + + return true, trace.Wrap(err) +} + +// Add is called to execute "scoped tokens add ..." command. +func (c *ScopedTokensCommand) Add(ctx context.Context, client *authclient.Client) error { + // Parse string to see if it's a type of role that Teleport supports. + roles, err := types.ParseTeleportRoles(c.tokenType) + if err != nil { + return trace.Wrap(err) + } + + // If it's Kube, then enable App and Discovery roles automatically so users + // don't have problems with running Kubernetes App Discovery by default. + if len(roles) == 1 && roles[0] == types.RoleKube { + roles = append(roles, types.RoleApp, types.RoleDiscovery) + } + + tokenName := c.value + + expires := time.Now().UTC().Add(c.ttl) + tok := &joiningv1.ScopedToken{ + Kind: types.KindScopedToken, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: tokenName, + Expires: timestamppb.New(expires), + }, + Scope: c.tokenScope, + Spec: &joiningv1.ScopedTokenSpec{ + Roles: roles.StringSlice(), + AssignedScope: c.assignedScope, + }, + } + + tok, err = client.CreateScopedToken(ctx, tok) + if err != nil { + if trace.IsAlreadyExists(err) { + return trace.AlreadyExists( + "failed to create scoped token (%q already exists), please use another name", + tokenName, + ) + } + return trace.Wrap(err, "creating scoped token") + } + + tokenName = tok.GetMetadata().GetName() + // Print token information formatted with JSON, YAML, or just print the raw token. + switch c.format { + case teleport.JSON, teleport.YAML: + expires := time.Now().Add(c.ttl) + tokenInfo := map[string]any{ + "token": tokenName, + "roles": roles, + "scope": tok.GetScope(), + "assign_scope": tok.GetSpec().GetAssignedScope(), + "expires": expires, + } + + var ( + data []byte + err error + ) + if c.format == teleport.JSON { + data, err = json.MarshalIndent(tokenInfo, "", " ") + } else { + data, err = yaml.Marshal(tokenInfo) + } + if err != nil { + return trace.Wrap(err) + } + fmt.Fprint(c.Stdout, string(data)) + + return nil + case teleport.Text: + fmt.Fprintln(c.Stdout, tokenName) + return nil + } + + return trace.Wrap(showJoinInstructions(ctx, joinInstructionsInput{ + out: c.Stdout, + ttl: c.ttl, + roles: roles, + tokenName: tokenName, + client: client, + })) +} + +// Del is called to execute "scoped tokens del ..." command. +func (c *ScopedTokensCommand) Del(ctx context.Context, client *authclient.Client) error { + if c.value == "" { + return trace.BadParameter("Need an argument: token") + } + if err := client.DeleteScopedToken(ctx, c.value); err != nil { + return trace.Wrap(err) + } + fmt.Fprintf(c.Stdout, "Token %s has been deleted\n", c.value) + return nil +} + +// List is called to execute "tokens ls" command. +func (c *ScopedTokensCommand) List(ctx context.Context, client *authclient.Client) error { + tokens, err := stream.Collect(clientutils.Resources(ctx, func(ctx context.Context, pageSize int, pageKey string) ([]*joiningv1.ScopedToken, string, error) { + res, err := client.ListScopedTokens(ctx, &joiningv1.ListScopedTokensRequest{ + Limit: uint32(pageSize), + Cursor: pageKey, + }) + if err != nil { + return nil, "", trace.Wrap(err) + } + + return res.GetTokens(), res.GetCursor(), nil + })) + if err != nil { + return trace.Wrap(err, "listing scoped tokens") + } + + if len(tokens) == 0 && c.format == teleport.Text { + fmt.Fprintln(c.Stdout, "No active tokens found.") + return nil + } + + // Sort by expire time. + slices.SortStableFunc(tokens, func(left, right *joiningv1.ScopedToken) int { + return left.GetMetadata().GetExpires().AsTime().Compare(right.GetMetadata().GetExpires().AsTime()) + }) + + nameFunc := func(tok *joiningv1.ScopedToken) string { + if c.withSecrets { + return tok.GetMetadata().GetName() + } + return token.GetSafeScopedTokenName(tok) + } + switch c.format { + case teleport.JSON: + err := utils.WriteJSONArray(c.Stdout, tokens) + if err != nil { + return trace.Wrap(err, "failed to marshal tokens") + } + case teleport.YAML: + err := utils.WriteYAML(c.Stdout, tokens) + if err != nil { + return trace.Wrap(err, "failed to marshal tokens") + } + case teleport.Text: + for _, token := range tokens { + fmt.Fprintln(c.Stdout, nameFunc(token)) + } + default: + tokensView := func() string { + table := asciitable.MakeTable([]string{"Token", "Type", "Scope", "Assigns Scope", "Labels", "Expiry Time (UTC)"}) + now := time.Now() + for _, t := range tokens { + expiry := "never" + expiresAt := t.GetMetadata().GetExpires().AsTime() + if !expiresAt.IsZero() && expiresAt.Unix() != 0 { + exptime := expiresAt.Format(time.RFC822) + expdur := expiresAt.Sub(now).Round(time.Second) + expiry = fmt.Sprintf("%s (%s)", exptime, expdur.String()) + } + table.AddRow([]string{nameFunc(t), strings.Join(t.GetSpec().GetRoles(), ","), t.GetScope(), t.GetSpec().GetAssignedScope(), printMetadataLabels(t.GetMetadata().Labels), expiry}) + } + return table.AsBuffer().String() + } + fmt.Fprint(c.Stdout, tokensView()) + } + return nil +} diff --git a/tool/tctl/common/scoped_token_command_test.go b/tool/tctl/common/scoped_token_command_test.go new file mode 100644 index 0000000000000..95f2c34981fb3 --- /dev/null +++ b/tool/tctl/common/scoped_token_command_test.go @@ -0,0 +1,125 @@ +/* + * Teleport + * Copyright (C) 2025 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 . + */ + +package common + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integration/helpers" + "github.com/gravitational/teleport/lib/config" + "github.com/gravitational/teleport/tool/teleport/testenv" +) + +type listedScopedToken struct { + Kind string + Version string + Metadata struct { + Name string + Expires timestamppb.Timestamp + ID uint + } + Spec struct { + Roles []string + JoinMethod string + } +} + +func TestScopedTokens(t *testing.T) { + dynAddr := helpers.NewDynamicServiceAddr(t) + fileConfig := &config.FileConfig{ + Global: config.Global{ + DataDir: t.TempDir(), + }, + Apps: config.Apps{ + Service: config.Service{ + EnabledFlag: "true", + }, + }, + Proxy: config.Proxy{ + Service: config.Service{ + EnabledFlag: "true", + }, + WebAddr: dynAddr.WebAddr, + TunAddr: dynAddr.TunnelAddr, + }, + Auth: config.Auth{ + Service: config.Service{ + EnabledFlag: "true", + ListenAddress: dynAddr.AuthAddr, + }, + }, + } + + process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors)) + clt, err := testenv.NewDefaultAuthClient(process) + require.NoError(t, err) + t.Cleanup(func() { _ = clt.Close() }) + + scopeFlags := []string{"--scope=/aa", "--assign-scope=/aa/bb"} + // Test all output formats of "tokens add". + buf, err := runScopedCommand(t, clt, append([]string{"tokens", "add", "--type=node"}, scopeFlags...)) + require.NoError(t, err) + require.True(t, strings.HasPrefix(buf.String(), "The invite token:")) + + buf, err = runScopedCommand(t, clt, append([]string{"tokens", "add", "--type=node", "--format", teleport.Text}, scopeFlags...)) + require.NoError(t, err) + require.Equal(t, 1, strings.Count(buf.String(), "\n")) + + buf, err = runScopedCommand(t, clt, append([]string{"tokens", "add", "--type=node", "--format", teleport.JSON}, scopeFlags...)) + require.NoError(t, err) + out := mustDecodeJSON[addedToken](t, buf) + + require.Len(t, out.Roles, 1) + require.Equal(t, types.KindNode, strings.ToLower(out.Roles[0])) + + buf, err = runScopedCommand(t, clt, append([]string{"tokens", "add", "--type=node", "--format", teleport.YAML}, scopeFlags...)) + require.NoError(t, err) + out = mustDecodeYAML[addedToken](t, buf) + + require.Len(t, out.Roles, 1) + require.Equal(t, types.KindNode, strings.ToLower(out.Roles[0])) + + // Test all output formats of "tokens ls". + buf, err = runScopedCommand(t, clt, []string{"tokens", "ls"}) + require.NoError(t, err) + require.True(t, strings.HasPrefix(buf.String(), "Token ")) + require.Equal(t, 6, strings.Count(buf.String(), "\n")) // account for header lines + + buf, err = runScopedCommand(t, clt, []string{"tokens", "ls", "--format", teleport.Text}) + require.NoError(t, err) + require.Equal(t, 4, strings.Count(buf.String(), "\n")) + + buf, err = runScopedCommand(t, clt, []string{"tokens", "ls", "--format", teleport.JSON}) + require.NoError(t, err) + jsonOut := mustDecodeJSON[[]listedScopedToken](t, buf) + require.Len(t, jsonOut, 4) + + buf, err = runScopedCommand(t, clt, []string{"tokens", "ls", "--format", teleport.YAML}) + require.NoError(t, err) + yamlOut := []listedScopedToken{} + mustDecodeYAMLDocuments(t, buf, &yamlOut) + require.Len(t, yamlOut, 4) + require.Equal(t, jsonOut, yamlOut) +} diff --git a/tool/tctl/common/token_command.go b/tool/tctl/common/token_command.go index c718c76819fc0..52a29fc221c85 100644 --- a/tool/tctl/common/token_command.go +++ b/tool/tctl/common/token_command.go @@ -305,128 +305,18 @@ func (c *TokensCommand) Add(ctx context.Context, client *authclient.Client) erro return nil } - // Calculate the CA pins for this cluster. The CA pins are used by the - // client to verify the identity of the Auth Server. - localCAResponse, err := client.GetClusterCACert(ctx) - if err != nil { - return trace.Wrap(err) - } - caPins, err := tlsca.CalculatePins(localCAResponse.TLSCA) - if err != nil { - return trace.Wrap(err) - } - - // Get list of auth servers. Used to print friendly signup message. - authServers, err := client.GetAuthServers() - if err != nil { - return trace.Wrap(err) - } - if len(authServers) == 0 { - return trace.BadParameter("this cluster has no auth servers") - } - - // Print signup message. - switch { - case roles.Include(types.RoleKube): - proxies, err := client.GetProxies() - if err != nil { - return trace.Wrap(err) - } - if len(proxies) == 0 { - return trace.NotFound("cluster has no proxies") - } - setRoles := strings.ToLower(strings.Join(roles.StringSlice(), "\\,")) - return kubeMessageTemplate.Execute(c.Stdout, - map[string]any{ - "auth_server": proxies[0].GetPublicAddr(), - "token": token, - "minutes": c.ttl.Minutes(), - "set_roles": setRoles, - "version": proxies[0].GetTeleportVersion(), - }) - case roles.Include(types.RoleApp): - proxies, err := client.GetProxies() - if err != nil { - return trace.Wrap(err) - } - if len(proxies) == 0 { - return trace.BadParameter("cluster has no proxies") - } - appPublicAddr := fmt.Sprintf("%v.%v", c.appName, proxies[0].GetPublicAddr()) - - return appMessageTemplate.Execute(c.Stdout, - map[string]any{ - "token": token, - "minutes": c.ttl.Minutes(), - "ca_pins": caPins, - "auth_server": proxies[0].GetPublicAddr(), - "app_name": c.appName, - "app_uri": c.appURI, - "app_public_addr": appPublicAddr, - }) - case roles.Include(types.RoleDatabase): - proxies, err := client.GetProxies() - if err != nil { - return trace.Wrap(err) - } - if len(proxies) == 0 { - return trace.NotFound("cluster has no proxies") - } - return dbMessageTemplate.Execute(c.Stdout, - map[string]any{ - "token": token, - "minutes": c.ttl.Minutes(), - "ca_pins": caPins, - "auth_server": proxies[0].GetPublicAddr(), - "db_name": c.dbName, - "db_protocol": c.dbProtocol, - "db_uri": c.dbURI, - }) - case roles.Include(types.RoleTrustedCluster): - fmt.Fprintf(c.Stdout, trustedClusterMessage, - token, - int(c.ttl.Minutes())) - case roles.Include(types.RoleWindowsDesktop): - return desktopMessageTemplate.Execute(c.Stdout, - map[string]any{ - "token": token, - "minutes": c.ttl.Minutes(), - }) - case roles.Include(types.RoleMDM): - return mdmTokenAddTemplate.Execute(c.Stdout, map[string]any{ - "token": token, - "minutes": c.ttl.Minutes(), - "ca_pins": caPins, - }) - default: - authServer := authServers[0].GetAddr() - - pingResponse, err := client.Ping(ctx) - if err != nil { - slog.DebugContext(ctx, "unable to ping auth client", "error", err) - } - - if err == nil && pingResponse.GetServerFeatures().Cloud { - proxies, err := client.GetProxies() - if err != nil { - return trace.Wrap(err) - } - - if len(proxies) != 0 { - authServer = proxies[0].GetPublicAddr() - } - } - - return nodeMessageTemplate.Execute(c.Stdout, map[string]any{ - "token": token, - "roles": strings.ToLower(roles.String()), - "minutes": int(c.ttl.Minutes()), - "ca_pins": caPins, - "auth_server": authServer, - }) - } - - return nil + return trace.Wrap(showJoinInstructions(ctx, joinInstructionsInput{ + out: c.Stdout, + client: client, + roles: roles, + tokenName: token, + ttl: c.ttl, + appName: c.appName, + appURI: c.appURI, + dbName: c.dbName, + dbURI: c.dbURI, + dbProtocol: c.dbProtocol, + })) } // Del is called to execute "tokens del ..." command. @@ -1064,3 +954,141 @@ func generateAgentValues(params valueGeneratorParams) ([]byte, error) { return yaml.Marshal(agentValues) } + +type joinInstructionsInput struct { + client *authclient.Client + roles types.SystemRoles + out io.Writer + tokenName string + ttl time.Duration + appName string + appURI string + dbName string + dbURI string + dbProtocol string +} + +func showJoinInstructions(ctx context.Context, in joinInstructionsInput) error { + // Calculate the CA pins for this cluster. The CA pins are used by the + // client to verify the identity of the Auth Server. + localCAResponse, err := in.client.GetClusterCACert(ctx) + if err != nil { + return trace.Wrap(err) + } + caPins, err := tlsca.CalculatePins(localCAResponse.TLSCA) + if err != nil { + return trace.Wrap(err) + } + + // Get list of auth servers. Used to print friendly signup message. + authServers, err := in.client.GetAuthServers() + if err != nil { + return trace.Wrap(err) + } + if len(authServers) == 0 { + return trace.BadParameter("this cluster has no auth servers") + } + + // Print signup message. + switch { + case in.roles.Include(types.RoleKube): + proxies, err := in.client.GetProxies() + if err != nil { + return trace.Wrap(err) + } + if len(proxies) == 0 { + return trace.NotFound("cluster has no proxies") + } + setRoles := strings.ToLower(strings.Join(in.roles.StringSlice(), "\\,")) + return kubeMessageTemplate.Execute(in.out, + map[string]any{ + "auth_server": proxies[0].GetPublicAddr(), + "token": in.tokenName, + "minutes": in.ttl.Minutes(), + "set_roles": setRoles, + "version": proxies[0].GetTeleportVersion(), + }) + case in.roles.Include(types.RoleApp): + proxies, err := in.client.GetProxies() + if err != nil { + return trace.Wrap(err) + } + if len(proxies) == 0 { + return trace.BadParameter("cluster has no proxies") + } + appPublicAddr := fmt.Sprintf("%v.%v", in.appName, proxies[0].GetPublicAddr()) + + return appMessageTemplate.Execute(in.out, + map[string]any{ + "token": in.tokenName, + "minutes": in.ttl.Minutes(), + "ca_pins": caPins, + "auth_server": proxies[0].GetPublicAddr(), + "app_name": in.appName, + "app_uri": in.appURI, + "app_public_addr": appPublicAddr, + }) + case in.roles.Include(types.RoleDatabase): + proxies, err := in.client.GetProxies() + if err != nil { + return trace.Wrap(err) + } + if len(proxies) == 0 { + return trace.NotFound("cluster has no proxies") + } + return dbMessageTemplate.Execute(in.out, + map[string]any{ + "token": in.tokenName, + "minutes": in.ttl.Minutes(), + "ca_pins": caPins, + "auth_server": proxies[0].GetPublicAddr(), + "db_name": in.dbName, + "db_protocol": in.dbProtocol, + "db_uri": in.dbURI, + }) + case in.roles.Include(types.RoleTrustedCluster): + fmt.Fprintf(in.out, trustedClusterMessage, + in.tokenName, + int(in.ttl.Minutes())) + case in.roles.Include(types.RoleWindowsDesktop): + return desktopMessageTemplate.Execute(in.out, + map[string]any{ + "token": in.tokenName, + "minutes": in.ttl.Minutes(), + }) + case in.roles.Include(types.RoleMDM): + return mdmTokenAddTemplate.Execute(in.out, map[string]any{ + "token": in.tokenName, + "minutes": in.ttl.Minutes(), + "ca_pins": caPins, + }) + default: + authServer := authServers[0].GetAddr() + + pingResponse, err := in.client.Ping(ctx) + if err != nil { + slog.DebugContext(ctx, "unable to ping auth client", "error", err) + } + + if err == nil && pingResponse.GetServerFeatures().Cloud { + proxies, err := in.client.GetProxies() + if err != nil { + return trace.Wrap(err) + } + + if len(proxies) != 0 { + authServer = proxies[0].GetPublicAddr() + } + } + + return nodeMessageTemplate.Execute(in.out, map[string]any{ + "token": in.tokenName, + "roles": strings.ToLower(in.roles.String()), + "minutes": int(in.ttl.Minutes()), + "ca_pins": caPins, + "auth_server": authServer, + }) + } + + return nil +}