diff --git a/lib/boundkeypair/bound_keypair.go b/lib/boundkeypair/bound_keypair.go index e35c10f8f9f21..2841f41aa13c6 100644 --- a/lib/boundkeypair/bound_keypair.go +++ b/lib/boundkeypair/bound_keypair.go @@ -67,6 +67,16 @@ func RecoveryModes() []RecoveryMode { } } +// RecoveryModeStrings returns a list of all supported recovery modes, typed as +// strings appropriate for use in CLI libraries. +func RecoveryModeStrings() []string { + return []string{ + string(RecoveryModeStandard), + string(RecoveryModeRelaxed), + string(RecoveryModeInsecure), + } +} + // ParseRecoveryMode parses a recovery mode from its string form. func ParseRecoveryMode(s string) (RecoveryMode, error) { switch s { diff --git a/lib/tbot/config/joinuri/uri.go b/lib/tbot/config/joinuri/uri.go new file mode 100644 index 0000000000000..d88f491a48d36 --- /dev/null +++ b/lib/tbot/config/joinuri/uri.go @@ -0,0 +1,210 @@ +/* + * 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 joinuri + +import ( + "fmt" + "net/url" + "slices" + "strings" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/tbot/bot/connection" + "github.com/gravitational/teleport/lib/tbot/bot/onboarding" +) + +const ( + // URISchemePrefix is the prefix for + URISchemePrefix = "tbot" + + // BoundKeypairSafeName is a URL scheme-safe name for the bound_keypair join + // method. + BoundKeypairSafeName = "bound-keypair" + + // AzureDevopsSafeName is a URL scheme-safe name for the azure_devops join + // method. + AzureDevopsSafeName = "azure-devops" + + // TerraformCloudSafeName is a URL scheme-safe name for the terraform_cloud + // join method. + TerraformCloudSafeName = "terraform-cloud" +) + +type JoinURI struct { + // AddressKind is the type of joining address, i.e. proxy or auth. + AddressKind connection.AddressKind + + // JoinMethod is the join method to use when joining, in combination with + // the token name. + JoinMethod types.JoinMethod + + // Token is the token name to use when joining + Token string + + // JoinMethodParameter is an optional parameter to pass to the join method. + // Its specific meaning depends on the join method in use. + JoinMethodParameter string + + // Address is either an auth or proxy address, depending on the configured + // AddressKind. It includes the port. + Address string +} + +func (u *JoinURI) ToURL() *url.URL { + // Assume "proxy" + kind := string(connection.AddressKindProxy) + if u.AddressKind == connection.AddressKindAuth { + kind = string(connection.AddressKindAuth) + } + + method := MapJoinMethodToURLSafe(u.JoinMethod) + + var info *url.Userinfo + if u.JoinMethodParameter != "" { + info = url.UserPassword(u.Token, u.JoinMethodParameter) + } else { + info = url.User(u.Token) + } + + return &url.URL{ + Scheme: fmt.Sprintf("%s+%s+%s", URISchemePrefix, kind, method), + User: info, + Host: u.Address, + } +} + +func (u *JoinURI) String() string { + return u.ToURL().String() +} + +// MapURLSafeJoinMethod converts a URL safe join method name to a defined join +// method constant. +func MapURLSafeJoinMethod(name string) (types.JoinMethod, error) { + // When given a join method name that is already URL safe, just return it. + if slices.Contains(onboarding.SupportedJoinMethods, name) { + return types.JoinMethod(name), nil + } + + // Various join methods contain underscores ("_") which are not valid + // characters in URL schemes, and must be mapped from something valid. + switch name { + case "bound-keypair", "boundkeypair": + return types.JoinMethodBoundKeypair, nil + case "azure-devops", "azuredevops": + return types.JoinMethodAzureDevops, nil + case "terraform-cloud", "terraformcloud": + return types.JoinMethodTerraformCloud, nil + default: + return types.JoinMethodUnspecified, trace.BadParameter("unsupported join method %q", name) + } +} + +// MapJoinMethodToURLSafe converts a join method name to a URL-safe string. +func MapJoinMethodToURLSafe(m types.JoinMethod) string { + switch m { + case types.JoinMethodBoundKeypair: + return BoundKeypairSafeName + case types.JoinMethodAzureDevops: + return AzureDevopsSafeName + case types.JoinMethodTerraformCloud: + return TerraformCloudSafeName + default: + return string(m) + } +} + +// ParseJoinURI parses a joining URI from its string form. It returns an error +// if the input URI is malformed, missing parameters, or references an unknown +// or invalid join method or connection type. +func Parse(s string) (*JoinURI, error) { + uri, err := url.Parse(s) + if err != nil { + return nil, trace.Wrap(err, "parsing joining URI") + } + + schemeParts := strings.SplitN(uri.Scheme, "+", 3) + if len(schemeParts) != 3 { + return nil, trace.BadParameter("unsupported joining URI scheme: %q", uri.Scheme) + } + + if schemeParts[0] != URISchemePrefix { + return nil, trace.BadParameter( + "unsupported joining URI scheme %q: scheme prefix must be %q", + uri.Scheme, URISchemePrefix) + } + + var kind connection.AddressKind + switch schemeParts[1] { + case string(connection.AddressKindProxy): + kind = connection.AddressKindProxy + case string(connection.AddressKindAuth): + kind = connection.AddressKindAuth + default: + return nil, trace.BadParameter( + "unsupported joining URI scheme %q: address kind must be one of [%q, %q], got: %q", + uri.Scheme, connection.AddressKindProxy, connection.AddressKindAuth, schemeParts[1]) + } + + joinMethod, err := MapURLSafeJoinMethod(schemeParts[2]) + if err != nil { + return nil, trace.Wrap(err) + } + + if uri.User == nil { + return nil, trace.BadParameter("invalid joining URI: must contain join token in user field") + } + + param, _ := uri.User.Password() + return &JoinURI{ + AddressKind: kind, + JoinMethod: joinMethod, + Token: uri.User.Username(), + JoinMethodParameter: param, + Address: uri.Host, + }, nil +} + +// FromProvisionToken returns a JoinURI for the given proxy address using fields +// from the given provision token where available. +func FromProvisionToken(token types.ProvisionToken, proxyAddr string) (*JoinURI, error) { + ptv2, ok := token.(*types.ProvisionTokenV2) + if !ok { + return nil, trace.BadParameter("expected *types.ProvisionTokenV2, got %T", token) + } + + // Attempt to determine the join method parameter where possible. This is + // method specific and occasionally refers to a client-side parameter, so it + // cannot always be filled from information in the provision token. + parameter := "" + switch ptv2.GetJoinMethod() { + case types.JoinMethodBoundKeypair: + // Will be empty if already registered, or if a keypair was provided. + parameter = ptv2.Status.BoundKeypair.RegistrationSecret + } + + return &JoinURI{ + JoinMethod: token.GetJoinMethod(), + Token: token.GetName(), + JoinMethodParameter: parameter, + AddressKind: connection.AddressKindProxy, + Address: proxyAddr, + }, nil +} diff --git a/lib/tbot/config/joinuri/uri_test.go b/lib/tbot/config/joinuri/uri_test.go new file mode 100644 index 0000000000000..fa81c1f3afa6e --- /dev/null +++ b/lib/tbot/config/joinuri/uri_test.go @@ -0,0 +1,96 @@ +/* + * 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 joinuri_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/tbot/bot/connection" + "github.com/gravitational/teleport/lib/tbot/config/joinuri" +) + +func TestParseJoinURI(t *testing.T) { + tests := []struct { + uri string + expect *joinuri.JoinURI + expectError require.ErrorAssertionFunc + }{ + { + uri: "tbot+proxy+token://asdf@example.com:1234", + expect: &joinuri.JoinURI{ + AddressKind: connection.AddressKindProxy, + Token: "asdf", + JoinMethod: types.JoinMethodToken, + Address: "example.com:1234", + JoinMethodParameter: "", + }, + }, + { + uri: "tbot+auth+bound-keypair://token:param@example.com", + expect: &joinuri.JoinURI{ + AddressKind: connection.AddressKindAuth, + Token: "token", + JoinMethod: types.JoinMethodBoundKeypair, + Address: "example.com", + JoinMethodParameter: "param", + }, + }, + { + uri: "", + expectError: func(tt require.TestingT, err error, i ...any) { + require.ErrorContains(tt, err, "unsupported joining URI scheme") + }, + }, + { + uri: "tbot+foo+token://example.com", + expectError: func(tt require.TestingT, err error, i ...any) { + require.ErrorContains(tt, err, "address kind must be one of") + }, + }, + { + uri: "tbot+proxy+bar://example.com", + expectError: func(tt require.TestingT, err error, i ...any) { + require.ErrorContains(tt, err, "unsupported join method") + }, + }, + { + uri: "https://example.com", + expectError: func(tt require.TestingT, err error, i ...any) { + require.ErrorContains(tt, err, "unsupported joining URI scheme") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.uri, func(t *testing.T) { + parsed, err := joinuri.Parse(tt.uri) + if tt.expectError == nil { + require.NoError(t, err) + } else { + tt.expectError(t, err) + } + + require.Empty(t, cmp.Diff(parsed, tt.expect)) + }) + } +} diff --git a/lib/tbot/config/uri.go b/lib/tbot/config/uri.go index 42c106b3adfe7..f30f6f1e04ace 100644 --- a/lib/tbot/config/uri.go +++ b/lib/tbot/config/uri.go @@ -1,6 +1,6 @@ /* * Teleport - * Copyright (C) 2025 Gravitational, Inc. + * Copyright (C) 2026 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 @@ -21,42 +21,14 @@ package config import ( "context" "log/slog" - "net/url" - "slices" - "strings" "github.com/gravitational/trace" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/tbot/bot/connection" - "github.com/gravitational/teleport/lib/tbot/bot/onboarding" + joinuri "github.com/gravitational/teleport/lib/tbot/config/joinuri" ) -const ( - // URISchemePrefix is the prefix for - URISchemePrefix = "tbot" -) - -type JoinURIParams struct { - // AddressKind is the type of joining address, i.e. proxy or auth. - AddressKind connection.AddressKind - - // JoinMethod is the join method to use when joining, in combination with - // the token name. - JoinMethod types.JoinMethod - - // Token is the token name to use when joining - Token string - - // JoinMethodParameter is an optional parameter to pass to the join method. - // Its specific meaning depends on the join method in use. - JoinMethodParameter string - - // Address is either an auth or proxy address, depending on the configured - // AddressKind. It includes the port. - Address string -} - // applyValueOrError sets the target `target` to the value `value`, but only if // the current value is that type's zero value, or if the current value is equal // to the desired value. If not, an error is returned per the error message @@ -79,7 +51,7 @@ func applyValueOrError[T comparable](target *T, value T, errMsg string, errArgs // config. This is designed to be applied to a configuration that has already // been loaded - but not yet validated - and returns an error if any fields in // the URI will conflict with those already set in the existing configuration. -func (p *JoinURIParams) ApplyToConfig(cfg *BotConfig) error { +func ApplyJoinURIToConfig(uri *joinuri.JoinURI, cfg *BotConfig) error { var errors []error if cfg.AuthServer != "" { @@ -87,29 +59,29 @@ func (p *JoinURIParams) ApplyToConfig(cfg *BotConfig) error { } else if cfg.ProxyServer != "" { errors = append(errors, trace.BadParameter("URI conflicts with configured field: proxy_server")) } else { - switch p.AddressKind { + switch uri.AddressKind { case connection.AddressKindAuth: - cfg.AuthServer = p.Address + cfg.AuthServer = uri.Address default: // this parameter should not be unspecified due to checks in // ParseJoinURI, so we'll assume proxy. - cfg.ProxyServer = p.Address + cfg.ProxyServer = uri.Address } } errors = append(errors, applyValueOrError( - &cfg.Onboarding.JoinMethod, p.JoinMethod, - "URI joining method %q conflicts with configured field: onboarding.join_method", p.JoinMethod)) + &cfg.Onboarding.JoinMethod, uri.JoinMethod, + "URI joining method %q conflicts with configured field: onboarding.join_method", uri.JoinMethod)) if cfg.Onboarding.TokenValue != "" { errors = append(errors, trace.BadParameter("URI conflicts with configured field: onboarding.token")) } else { - cfg.Onboarding.SetToken(p.Token) + cfg.Onboarding.SetToken(uri.Token) } // The join method parameter maps to a method-specific field when set. - if param := p.JoinMethodParameter; param != "" { - switch p.JoinMethod { + if param := uri.JoinMethodParameter; param != "" { + switch uri.JoinMethod { case types.JoinMethodAzure: errors = append(errors, applyValueOrError( &cfg.Onboarding.Azure.ClientID, param, @@ -131,83 +103,10 @@ func (p *JoinURIParams) ApplyToConfig(cfg *BotConfig) error { slog.WarnContext( context.Background(), "ignoring join method parameter for unsupported join method", - "join_method", p.JoinMethod, + "join_method", uri.JoinMethod, ) } } return trace.NewAggregate(errors...) } - -// MapURLSafeJoinMethod converts a URL safe join method name to a defined join -// method constant. -func MapURLSafeJoinMethod(name string) (types.JoinMethod, error) { - // When given a join method name that is already URL safe, just return it. - if slices.Contains(onboarding.SupportedJoinMethods, name) { - return types.JoinMethod(name), nil - } - - // Various join methods contain underscores ("_") which are not valid - // characters in URL schemes, and must be mapped from something valid. - switch name { - case "bound-keypair", "boundkeypair": - return types.JoinMethodBoundKeypair, nil - case "azure-devops", "azuredevops": - return types.JoinMethodAzureDevops, nil - case "terraform-cloud", "terraformcloud": - return types.JoinMethodTerraformCloud, nil - default: - return types.JoinMethodUnspecified, trace.BadParameter("unsupported join method %q", name) - } -} - -// ParseJoinURI parses a joining URI from its string form. It returns an error -// if the input URI is malformed, missing parameters, or references an unknown -// or invalid join method or connection type. -func ParseJoinURI(s string) (*JoinURIParams, error) { - uri, err := url.Parse(s) - if err != nil { - return nil, trace.Wrap(err, "parsing joining URI") - } - - schemeParts := strings.SplitN(uri.Scheme, "+", 3) - if len(schemeParts) != 3 { - return nil, trace.BadParameter("unsupported joining URI scheme: %q", uri.Scheme) - } - - if schemeParts[0] != URISchemePrefix { - return nil, trace.BadParameter( - "unsupported joining URI scheme %q: scheme prefix must be %q", - uri.Scheme, URISchemePrefix) - } - - var kind connection.AddressKind - switch schemeParts[1] { - case string(connection.AddressKindProxy): - kind = connection.AddressKindProxy - case string(connection.AddressKindAuth): - kind = connection.AddressKindAuth - default: - return nil, trace.BadParameter( - "unsupported joining URI scheme %q: address kind must be one of [%q, %q], got: %q", - uri.Scheme, connection.AddressKindProxy, connection.AddressKindAuth, schemeParts[1]) - } - - joinMethod, err := MapURLSafeJoinMethod(schemeParts[2]) - if err != nil { - return nil, trace.Wrap(err) - } - - if uri.User == nil { - return nil, trace.BadParameter("invalid joining URI: must contain join token in user field") - } - - param, _ := uri.User.Password() - return &JoinURIParams{ - AddressKind: kind, - JoinMethod: joinMethod, - Token: uri.User.Username(), - JoinMethodParameter: param, - Address: uri.Host, - }, nil -} diff --git a/lib/tbot/config/uri_test.go b/lib/tbot/config/uri_test.go index e3e65f0926d42..a16b795fb6bc4 100644 --- a/lib/tbot/config/uri_test.go +++ b/lib/tbot/config/uri_test.go @@ -1,6 +1,6 @@ /* * Teleport - * Copyright (C) 2025 Gravitational, Inc. + * Copyright (C) 2026 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 @@ -21,80 +21,13 @@ package config import ( "testing" - "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/lib/tbot/bot/connection" "github.com/gravitational/teleport/lib/tbot/bot/onboarding" + "github.com/gravitational/teleport/lib/tbot/config/joinuri" ) -func TestParseJoinURI(t *testing.T) { - tests := []struct { - uri string - expect *JoinURIParams - expectError require.ErrorAssertionFunc - }{ - { - uri: "tbot+proxy+token://asdf@example.com:1234", - expect: &JoinURIParams{ - AddressKind: connection.AddressKindProxy, - Token: "asdf", - JoinMethod: types.JoinMethodToken, - Address: "example.com:1234", - JoinMethodParameter: "", - }, - }, - { - uri: "tbot+auth+bound-keypair://token:param@example.com", - expect: &JoinURIParams{ - AddressKind: connection.AddressKindAuth, - Token: "token", - JoinMethod: types.JoinMethodBoundKeypair, - Address: "example.com", - JoinMethodParameter: "param", - }, - }, - { - uri: "", - expectError: func(tt require.TestingT, err error, i ...any) { - require.ErrorContains(tt, err, "unsupported joining URI scheme") - }, - }, - { - uri: "tbot+foo+token://example.com", - expectError: func(tt require.TestingT, err error, i ...any) { - require.ErrorContains(tt, err, "address kind must be one of") - }, - }, - { - uri: "tbot+proxy+bar://example.com", - expectError: func(tt require.TestingT, err error, i ...any) { - require.ErrorContains(tt, err, "unsupported join method") - }, - }, - { - uri: "https://example.com", - expectError: func(tt require.TestingT, err error, i ...any) { - require.ErrorContains(tt, err, "unsupported joining URI scheme") - }, - }, - } - - for _, tt := range tests { - t.Run(tt.uri, func(t *testing.T) { - parsed, err := ParseJoinURI(tt.uri) - if tt.expectError == nil { - require.NoError(t, err) - } else { - tt.expectError(t, err) - } - - require.Empty(t, cmp.Diff(parsed, tt.expect)) - }) - } -} - func TestJoinURIApplyToConfig(t *testing.T) { tests := []struct { uri string @@ -224,10 +157,10 @@ func TestJoinURIApplyToConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.uri, func(t *testing.T) { - parsed, err := ParseJoinURI(tt.uri) + parsed, err := joinuri.Parse(tt.uri) require.NoError(t, err) - err = parsed.ApplyToConfig(tt.inputConfig) + err = ApplyJoinURIToConfig(parsed, tt.inputConfig) if tt.expectError != nil { tt.expectError(t, err) } else { diff --git a/lib/tbot/tbot.go b/lib/tbot/tbot.go index 0229419b00954..fe6c298957b3e 100644 --- a/lib/tbot/tbot.go +++ b/lib/tbot/tbot.go @@ -39,6 +39,7 @@ import ( "github.com/gravitational/teleport/lib/tbot/bot" "github.com/gravitational/teleport/lib/tbot/bot/connection" "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/tbot/config/joinuri" "github.com/gravitational/teleport/lib/tbot/identity" "github.com/gravitational/teleport/lib/tbot/internal" "github.com/gravitational/teleport/lib/tbot/internal/diagnostics" @@ -316,12 +317,12 @@ func (b *Bot) preRunChecks(ctx context.Context) (_ func() error, err error) { defer func() { apitracing.EndSpan(span, err) }() if b.cfg.JoinURI != "" { - parsed, err := config.ParseJoinURI(b.cfg.JoinURI) + parsed, err := joinuri.Parse(b.cfg.JoinURI) if err != nil { return nil, trace.Wrap(err, "parsing joining URI") } - if err := parsed.ApplyToConfig(b.cfg); err != nil { + if err := config.ApplyJoinURIToConfig(parsed, b.cfg); err != nil { return nil, trace.Wrap(err, "applying joining URI to bot config") } } diff --git a/tool/tbot/main.go b/tool/tbot/main.go index 725c2b5a2409d..439bd500f66a7 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -40,6 +40,7 @@ import ( "github.com/gravitational/teleport/lib/tbot" "github.com/gravitational/teleport/lib/tbot/cli" "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/tbot/config/joinuri" "github.com/gravitational/teleport/lib/tpm" "github.com/gravitational/teleport/lib/utils" logutils "github.com/gravitational/teleport/lib/utils/log" @@ -366,7 +367,7 @@ func onConfigure( // Ensure they have provided either a valid joining URI, or a // join method to use in the configuration. if cfg.JoinURI != "" { - if _, err := config.ParseJoinURI(cfg.JoinURI); err != nil { + if _, err := joinuri.Parse(cfg.JoinURI); err != nil { return trace.Wrap(err, "invalid joining URI") } } else if cfg.Onboarding.JoinMethod == types.JoinMethodUnspecified { diff --git a/tool/tctl/common/bots_command.go b/tool/tctl/common/bots_command.go index ba62e06a75600..5fe9be62f2c72 100644 --- a/tool/tctl/common/bots_command.go +++ b/tool/tctl/common/bots_command.go @@ -51,9 +51,12 @@ import ( "github.com/gravitational/teleport/api/utils/clientutils" "github.com/gravitational/teleport/lib/asciitable" "github.com/gravitational/teleport/lib/auth/machineid/machineidv1" + "github.com/gravitational/teleport/lib/boundkeypair" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/itertools/stream" "github.com/gravitational/teleport/lib/service/servicecfg" + "github.com/gravitational/teleport/lib/sshutils" + "github.com/gravitational/teleport/lib/tbot/config/joinuri" "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/set" commonclient "github.com/gravitational/teleport/tool/tctl/common/client" @@ -66,13 +69,26 @@ type BotsCommand struct { lockExpires string lockTTL time.Duration - botName string - botRoles string - tokenID string - tokenTTL time.Duration - addRoles string - instanceID string - maxSessionTTL time.Duration + botName string + botRoles string + tokenID string + tokenTTL time.Duration + addRoles string + instanceID string + maxSessionTTL time.Duration + legacy bool + initialPublicKey string + recoveryMode string + recoveryLimit uint32 + registrationSecret string + + // testStaticToken is a static token name for use in tests and cannot be set + // as a CLI flag. + testStaticToken string + + // testMutateTemplateData modifies data before a template is rendered. Only + // useful in tests. + testMutateTemplateData func(data map[string]any) allowedLogins []string addLogins string @@ -97,6 +113,45 @@ type BotsCommand struct { stdout io.Writer } +// initSharedBotTokenFlags initializes flags shared between `bots add` and +// `bot instances add` +func (c *BotsCommand) initSharedBotTokenFlags(cmd *kingpin.CmdClause) { + cmd.Flag("token", "The token to use, if any. If unset, a new single-use token will be created.").StringVar(&c.tokenID) + cmd.Flag("format", "Output format, one of: text, json").Default(teleport.Text).EnumVar(&c.format, teleport.Text, teleport.JSON) + + // TODO(timothyb89): Remove in v20 (optional) + cmd.Flag("legacy", "If set, generate a legacy joining token instead of a bound keypair token. No effect if --token is set.").BoolVar(&c.legacy) + cmd.Flag( + "ttl", + "TTL for the bot join token. For standard bound keypair tokens, this "+ + "sets must_register_before; for legacy tokens, this sets the "+ + "resource TTL.", + ).Default(defaults.DefaultBotJoinTTL.String()).DurationVar(&c.tokenTTL) + cmd.Flag( + "initial-public-key", + "If set, use the given initial public key in SSH authorized_keys "+ + "format, instead of generating a registration secret. The value "+ + "must be quoted. Not compatible with --token or --legacy.", + ).StringVar(&c.initialPublicKey) + cmd.Flag( + "recovery-mode", + "If set, overrides the recovery mode for the bound keypair token. No "+ + "effect if --token or --legacy is set.", + ).Default(string(boundkeypair.RecoveryModeStandard)).EnumVar(&c.recoveryMode, boundkeypair.RecoveryModeStrings()...) + cmd.Flag( + "recovery-limit", + "Overrides the recovery limit (default: 1) for the bound keypair "+ + "token. No effect if --token or --legacy is set, or if "+ + "--recovery-mode is not standard. Must be greater than 1.", + ).Uint32Var(&c.recoveryLimit) + cmd.Flag( + "registration-secret", + "Sets a registration secret for the bound keypair token. If not set, "+ + "one will be randomly generated. No effect if "+ + "--initial-public-key, --token, or --legacy is set. ", + ).StringVar(&c.registrationSecret) +} + // Initialize sets up the "tctl bots" command. func (c *BotsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) { bots := app.Command("bots", "Manage Machine & Workload Identity bots on the cluster.").Alias("bot") @@ -104,14 +159,12 @@ func (c *BotsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIF c.botsList = bots.Command("ls", "List all certificate renewal bots registered with the cluster.") c.botsList.Flag("format", "Output format, 'text' or 'json'").Hidden().Default(teleport.Text).EnumVar(&c.format, teleport.Text, teleport.JSON) - c.botsAdd = bots.Command("add", "Add a new certificate renewal bot to the cluster.") + c.botsAdd = bots.Command("add", "Add a new bot to the cluster.") c.botsAdd.Arg("name", "A name to uniquely identify this bot in the cluster.").Required().StringVar(&c.botName) c.botsAdd.Flag("roles", "Roles the bot is able to assume.").StringVar(&c.botRoles) - c.botsAdd.Flag("ttl", "TTL for the bot join token.").DurationVar(&c.tokenTTL) - c.botsAdd.Flag("token", "Name of an existing token to use.").StringVar(&c.tokenID) - c.botsAdd.Flag("format", "Output format, 'text' or 'json'").Hidden().Default(teleport.Text).EnumVar(&c.format, teleport.Text, teleport.JSON) c.botsAdd.Flag("logins", "List of allowed SSH logins for the bot user").StringsVar(&c.allowedLogins) c.botsAdd.Flag("max-session-ttl", "Set a max session TTL for the bot's internal identity. 12h default, 168h maximum.").DurationVar(&c.maxSessionTTL) + c.initSharedBotTokenFlags(c.botsAdd) c.botsRemove = bots.Command("rm", "Permanently remove a certificate renewal bot from the cluster.") c.botsRemove.Arg("name", "Name of an existing bot to remove.").Required().StringVar(&c.botName) @@ -145,8 +198,7 @@ func (c *BotsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIF c.botsInstancesAdd = c.botsInstances.Command("add", "Join a new instance onto an existing bot.").Alias("join") c.botsInstancesAdd.Arg("name", "The name of the existing bot for which to add a new instance.").Required().StringVar(&c.botName) - c.botsInstancesAdd.Flag("token", "The token to use, if any. If unset, a new one-time-use token will be created.").StringVar(&c.tokenID) - c.botsInstancesAdd.Flag("format", "Output format, one of: text, json").Default(teleport.Text).EnumVar(&c.format, teleport.Text, teleport.JSON) + c.initSharedBotTokenFlags(c.botsInstancesAdd) if c.stdout == nil { c.stdout = os.Stdout @@ -180,6 +232,7 @@ func (c *BotsCommand) TryRun(ctx context.Context, cmd string, clientFunc commonc if err != nil { return false, trace.Wrap(err) } + err = commandFunc(ctx, client) closeFn(ctx) @@ -190,6 +243,7 @@ type botsCommandClient interface { BotServiceClient() machineidv1pb.BotServiceClient BotInstanceServiceClient() machineidv1pb.BotInstanceServiceClient + CreateToken(ctx context.Context, token types.ProvisionToken) error GetToken(ctx context.Context, name string) (types.ProvisionToken, error) UpsertToken(ctx context.Context, token types.ProvisionToken) error GetUser(ctx context.Context, name string, withSecrets bool) (types.User, error) @@ -252,7 +306,9 @@ func bold(text string) string { var startMessageTemplate = template.Must(template.New("node").Funcs(template.FuncMap{ "bold": bold, -}).Parse(`The bot token: {{.token}}{{if .minutes}} +}).Parse(` +The bot token: {{.token}}{{if .minutes}}{{ if .join_uri }} +The joining URI: {{ .join_uri }}{{ end }} This token will expire in {{.minutes}} minutes.{{end}} Optionally, if running the bot under an isolated user account, first initialize @@ -287,6 +343,95 @@ Please note: https://goteleport.com/docs/enroll-resources/machine-id/deployment/{{end}} `)) +var startMessageTemplateV2 = template.Must(template.New( + "bot", +).Parse(`The bot joining URI: {{ .join_uri }}{{ if and (.minutes) (eq .join_method "bound_keypair") }} +This token must be used within {{ .minutes }} minutes after which it must be recreated.{{ else if .minutes }} +This token will expire in {{ .minutes }} minutes.{{ end }} + +To start a new tbot running the identity service, run: + +> tbot start identity \ + --join-uri={{ .join_uri }} \ + --destination=./destination + +Alternatively, if you'd like to generate a tbot.yaml config file, you can +instead run: + +> tbot configure identity \ + --join-uri={{ .join_uri }} \ + --destination=./destination > tbot.yaml + +Then, run tbot with: + +> tbot start -c tbot.yaml + +Advanced parameters: +{{ .param_table }} +Please note: + - The ./destination destination directory can be changed as desired. + - /var/lib/teleport/bot must be accessible to the bot user, or --storage + must point to another accessible directory to store internal bot data. + - This example shows only use of the 'identity' service. See our documentation + for all supported service types: + https://goteleport.com/docs/reference/cli/tbot/{{ if eq .join_method "bound_keypair" }} + - This token will be permanently bound to a single 'tbot' instance upon first + join. For scalable alternatives, see our documentation on other supported + join methods: + https://goteleport.com/docs/enroll-resources/machine-id/deployment/{{ else if eq .join_method "token" }} + - This is a single-token that will be consumed upon usage. For scalable + alternatives, see our documentation on other supported join methods: + https://goteleport.com/docs/enroll-resources/machine-id/deployment/{{end}} +`)) + +func (c *BotsCommand) createBoundKeypairBotToken(ctx context.Context, client botsCommandClient) (types.ProvisionToken, error) { + initialPublicKey := c.initialPublicKey + if initialPublicKey != "" { + _, err := sshutils.CryptoPublicKey([]byte(initialPublicKey)) + if err != nil { + return nil, trace.Wrap(err, "--initial-public-key must contain a valid public key in SSH authorized_keys format") + } + } + + // For bound keypair tokens, the TTL applies to MustRegisterBefore + // rather than the resource TTL. The token itself should live + // indefinitely. + var mustRegisterBefore *time.Time + if c.tokenTTL > 0 { + t := time.Now().Add(c.tokenTTL) + mustRegisterBefore = &t + } + + var recoveryLimit uint32 = 1 + if c.recoveryLimit > 0 { + recoveryLimit = c.recoveryLimit + } + + spec := types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{types.RoleBot}, + JoinMethod: types.JoinMethodBoundKeypair, + BotName: c.botName, + BoundKeypair: &types.ProvisionTokenSpecV2BoundKeypair{ + Onboarding: &types.ProvisionTokenSpecV2BoundKeypair_OnboardingSpec{ + InitialPublicKey: initialPublicKey, + MustRegisterBefore: mustRegisterBefore, + RegistrationSecret: c.registrationSecret, + }, + Recovery: &types.ProvisionTokenSpecV2BoundKeypair_RecoverySpec{ + Mode: c.recoveryMode, + Limit: recoveryLimit, + }, + }, + } + + token, err := c.createUniqueBotToken(ctx, client, 0, spec) + if err != nil { + return nil, trace.Wrap(err, "creating join token") + } + + return token, nil +} + // AddBot adds a new certificate renewal bot to the cluster. func (c *BotsCommand) AddBot(ctx context.Context, client botsCommandClient) error { // Prompt for admin action MFA if required, allowing reuse for UpsertToken and CreateBot. @@ -302,12 +447,23 @@ func (c *BotsCommand) AddBot(ctx context.Context, client botsCommandClient) erro slog.WarnContext(ctx, "No roles specified - the bot will not be able to produce outputs until a role is added to the bot") } var token types.ProvisionToken - if c.tokenID == "" { - // If there's no token specified, generate one - tokenName, err := utils.CryptoRandomHex(defaults.TokenLenBytes) + switch { + case c.tokenID == "" && !c.legacy: + token, err = c.createBoundKeypairBotToken(ctx, client) if err != nil { return trace.Wrap(err) } + case c.tokenID == "" && c.legacy: + // If there's no token specified, generate one + var tokenName string + if c.testStaticToken != "" { + tokenName = c.testStaticToken + } else { + tokenName, err = utils.CryptoRandomHex(defaults.TokenLenBytes) + if err != nil { + return trace.Wrap(err) + } + } ttl := c.tokenTTL if ttl == 0 { ttl = defaults.DefaultBotJoinTTL @@ -324,7 +480,7 @@ func (c *BotsCommand) AddBot(ctx context.Context, client botsCommandClient) erro if err := client.UpsertToken(ctx, token); err != nil { return trace.Wrap(err) } - } else { + default: // If there is, check the token matches the potential bot token, err = client.GetToken(ctx, c.tokenID) if err != nil { @@ -374,7 +530,7 @@ func (c *BotsCommand) AddBot(ctx context.Context, client botsCommandClient) erro return trace.Wrap(err) } - return trace.Wrap(outputToken(ctx, c.stdout, c.format, client, bot, token)) + return trace.Wrap(c.outputToken(ctx, client, bot, token)) } func (c *BotsCommand) RemoveBot(ctx context.Context, client botsCommandClient) error { @@ -718,6 +874,13 @@ func (c *BotsCommand) AddBotInstance(ctx context.Context, client botsCommandClie // A bit of a misnomer but makes the terminology a bit more consistent. This // doesn't directly create a bot instance, but creates token that allows a // bot to join, which creates a new instance. + // Prompt for admin action MFA if required, allowing reuse for UpsertToken and CreateBot. + mfaResponse, err := mfa.PerformAdminActionMFACeremony(ctx, client.PerformMFACeremony, true /*allowReuse*/) + if err == nil { + ctx = mfa.ContextWithMFAResponse(ctx, mfaResponse) + } else if !errors.Is(err, &mfa.ErrMFANotRequired) && !errors.Is(err, &mfa.ErrMFANotSupported) { + return trace.Wrap(err) + } bot, err := client.BotServiceClient().GetBot(ctx, &machineidv1pb.GetBotRequest{ BotName: c.botName, @@ -726,9 +889,14 @@ func (c *BotsCommand) AddBotInstance(ctx context.Context, client botsCommandClie return trace.Wrap(err) } - var token types.ProvisionToken + if c.tokenID == "" && !c.legacy { + token, err := c.createBoundKeypairBotToken(ctx, client) + if err != nil { + return trace.Wrap(err) + } - if c.tokenID == "" { + return trace.Wrap(c.outputToken(ctx, client, bot, token)) + } else if c.tokenID == "" && c.legacy { // If there's no token specified, generate one tokenName, err := utils.CryptoRandomHex(defaults.TokenLenBytes) if err != nil { @@ -740,7 +908,7 @@ func (c *BotsCommand) AddBotInstance(ctx context.Context, client botsCommandClie JoinMethod: types.JoinMethodToken, BotName: c.botName, } - token, err = types.NewProvisionTokenFromSpec(tokenName, time.Now().Add(ttl), tokenSpec) + token, err := types.NewProvisionTokenFromSpec(tokenName, time.Now().Add(ttl), tokenSpec) if err != nil { return trace.Wrap(err) } @@ -748,7 +916,7 @@ func (c *BotsCommand) AddBotInstance(ctx context.Context, client botsCommandClie return trace.Wrap(err) } - return trace.Wrap(outputToken(ctx, c.stdout, c.format, client, bot, token)) + return trace.Wrap(c.outputToken(ctx, client, bot, token)) } // There's not much to do in this case, but we can validate the token. @@ -756,7 +924,7 @@ func (c *BotsCommand) AddBotInstance(ctx context.Context, client botsCommandClie // print joining instructions. // If there is, check the token matches the potential bot - token, err = client.GetToken(ctx, c.tokenID) + token, err := client.GetToken(ctx, c.tokenID) if err != nil { if trace.IsNotFound(err) { return trace.NotFound("token with name %q not found, create the token or do not set TokenName: %v", @@ -773,7 +941,7 @@ func (c *BotsCommand) AddBotInstance(ctx context.Context, client botsCommandClie c.tokenID, token.GetBotName(), c.botName) } - return trace.Wrap(outputToken(ctx, c.stdout, c.format, client, bot, token)) + return trace.Wrap(c.outputToken(ctx, client, bot, token)) } var showMessageTemplate = template.Must(template.New("show").Funcs(template.FuncMap{ @@ -859,18 +1027,46 @@ func (c *BotsCommand) ShowBotInstance(ctx context.Context, client botsCommandCli // botJSONResponse is a response generated by the `tctl bots add` family of // commands when the format is `json` type botJSONResponse struct { - UserName string `json:"user_name"` - RoleName string `json:"role_name"` - TokenID string `json:"token_id"` - TokenTTL time.Duration `json:"token_ttl"` + UserName string `json:"user_name"` + RoleName string `json:"role_name"` + TokenID string `json:"token_id"` + TokenTTL time.Duration `json:"token_ttl"` + JoinURI string `json:"join_uri"` + RegistrationSecret string `json:"registration_secret,omitempty"` } // outputToken writes token information to stdout, depending on the token format. -func outputToken(ctx context.Context, wr io.Writer, format string, client botsCommandClient, bot *machineidv1pb.Bot, token types.ProvisionToken) error { - if format == teleport.JSON { +func (c *BotsCommand) outputToken( + ctx context.Context, + client botsCommandClient, + bot *machineidv1pb.Bot, + token types.ProvisionToken, +) error { + proxies, err := clientutils.CollectWithFallback(ctx, client.ListProxyServers, func(context.Context) ([]types.Server, error) { + //nolint:staticcheck // TODO(kiosion) DELETE IN 21.0.0 + return client.GetProxies() + }) + if err != nil { + return trace.Wrap(err) + } + if len(proxies) == 0 { + return trace.Errorf("bot was created but this cluster does not have any proxy servers running so unable to display success message") + } + addr := cmp.Or(proxies[0].GetPublicAddr(), proxies[0].GetAddr()) + + uri, err := joinuri.FromProvisionToken(token, addr) + if err != nil { + return trace.Wrap(err, "generating joining URI") + } + + secret, _ := uri.ToURL().User.Password() + + if c.format == teleport.JSON { tokenTTL := time.Duration(0) if exp := token.Expiry(); !exp.IsZero() { tokenTTL = time.Until(exp) + } else if deadline := getBoundKeypairRegistrationDeadline(token); deadline != nil { + tokenTTL = time.Until(*deadline) } // This struct is equivalent to a legacy bit of JSON we used to output // when we called an older RPC. We've preserved it here to avoid @@ -880,42 +1076,103 @@ func outputToken(ctx context.Context, wr io.Writer, format string, client botsCo RoleName: bot.Status.RoleName, TokenID: token.GetName(), TokenTTL: tokenTTL, + JoinURI: uri.String(), + } + + // Only set registration_secret if the type is explicitly bound keypair. + // We don't currently have other values in the password field, but it is + // not exclusively reserved for registration secrets and may not have + // the same semantics for other methods in the future. + if token.GetJoinMethod() == types.JoinMethodBoundKeypair && secret != "" { + response.RegistrationSecret = secret } + out, err := json.MarshalIndent(response, "", " ") if err != nil { return trace.Wrap(err, "failed to marshal CreateBot response") } - fmt.Fprintln(wr, string(out)) + fmt.Fprintln(c.stdout, string(out)) return nil } - proxies, err := clientutils.CollectWithFallback(ctx, client.ListProxyServers, func(context.Context) ([]types.Server, error) { - //nolint:staticcheck // TODO(kiosion) DELETE IN 21.0.0 - return client.GetProxies() - }) - if err != nil { - return trace.Wrap(err) - } - if len(proxies) == 0 { - return trace.Errorf("bot was created but this cluster does not have any proxy servers running so unable to display success message") + if c.legacy { + joinMethod := token.GetJoinMethod() + if joinMethod == types.JoinMethodUnspecified { + joinMethod = types.JoinMethodToken + } + + templateData := map[string]any{ + "token": token.GetName(), + "addr": addr, + "join_method": joinMethod, + "join_uri": uri.String(), + } + if !token.Expiry().IsZero() { + templateData["minutes"] = int(time.Until(token.Expiry()).Minutes()) + } else if deadline := getBoundKeypairRegistrationDeadline(token); deadline != nil { + templateData["minutes"] = int(time.Until(*deadline).Minutes()) + } + + if c.testMutateTemplateData != nil { + c.testMutateTemplateData(templateData) + } + + return startMessageTemplate.Execute(c.stdout, templateData) } - addr := cmp.Or(proxies[0].GetPublicAddr(), proxies[0].GetAddr()) joinMethod := token.GetJoinMethod() if joinMethod == types.JoinMethodUnspecified { - joinMethod = types.JoinMethodToken + joinMethod = types.JoinMethodBoundKeypair + } + + paramTable := asciitable.MakeHeadlessTable(2) + paramTable.AddRow([]string{"Proxy:", addr}) + paramTable.AddRow([]string{"Token:", token.GetName()}) + paramTable.AddRow([]string{"Join Method:", string(joinMethod)}) + if secret != "" { + paramTable.AddRow([]string{"Registration Secret:", secret}) } templateData := map[string]any{ - "token": token.GetName(), - "addr": addr, "join_method": joinMethod, + "join_uri": uri.String(), + "param_table": indentString(paramTable.AsBuffer().String(), " "), } if !token.Expiry().IsZero() { templateData["minutes"] = int(time.Until(token.Expiry()).Minutes()) + } else if deadline := getBoundKeypairRegistrationDeadline(token); deadline != nil { + templateData["minutes"] = int(time.Until(*deadline).Minutes()) + } + + if c.testMutateTemplateData != nil { + c.testMutateTemplateData(templateData) + } + + return startMessageTemplateV2.Execute(c.stdout, templateData) +} + +func getBoundKeypairRegistrationDeadline(token types.ProvisionToken) *time.Time { + ptv2, ok := token.(*types.ProvisionTokenV2) + if !ok { + return nil + } + + spec := ptv2.Spec.BoundKeypair + if spec == nil { + return nil + } + + onboarding := spec.Onboarding + if onboarding == nil { + return nil } - return startMessageTemplate.Execute(wr, templateData) + + if onboarding.MustRegisterBefore == nil { + return nil + } + + return onboarding.MustRegisterBefore } // splitEntries splits a comma separated string into an array of entries, @@ -1079,3 +1336,58 @@ func aggregateServiceHealth(services []*machineidv1pb.BotInstanceServiceHealth) return true, machineidv1pb.BotInstanceHealthStatus_BOT_INSTANCE_HEALTH_STATUS_HEALTHY } + +// createUniqueBotToken attempts to create a new uniquely-named bot join token. +// It generates randomly-named tokens of the form `bot-$name-$suffix`, where +// `$name` is the bot name, and `$suffix` is a random hex string. It makes up to +// 2 retry attempts if the token name is already in use. If staticTokenName is +// set, that name will be used instead of a random name; this is only suitable +// for ensuring deterministic tests. +func (c *BotsCommand) createUniqueBotToken( + ctx context.Context, + client botsCommandClient, + ttl time.Duration, + spec types.ProvisionTokenSpecV2, +) (types.ProvisionToken, error) { + for i := 0; i < 3; i++ { + name := c.testStaticToken + if name == "" { + suffix, err := utils.CryptoRandomHex(4) + if err != nil { + return nil, trace.Wrap(err) + } + + name = fmt.Sprintf("bot-%s-%s", spec.BotName, suffix) + } + + token, err := types.NewProvisionTokenFromSpec(name, time.Now().Add(ttl), spec) + if err != nil { + return nil, trace.Wrap(err) + } + + // Ugly hack, but there's no other way to unset Expires or construct a + // token with a nil value. + if ttl == 0 { + meta := token.GetMetadata() + meta.Expires = nil + token.SetMetadata(meta) + } + + err = client.CreateToken(ctx, token) + if trace.IsAlreadyExists(err) { + slog.DebugContext(ctx, "Token already exists, will try again with new random name", "token", name) + continue + } else if err != nil { + return nil, trace.Wrap(err, "creating token") + } + + created, err := client.GetToken(ctx, name) + if err != nil { + return nil, trace.Wrap(err, "fetching created token") + } + + return created, nil + } + + return nil, trace.AlreadyExists("unable to create a new unique join token") +} diff --git a/tool/tctl/common/bots_command_test.go b/tool/tctl/common/bots_command_test.go index c0febbc745f0d..26c43e5a81749 100644 --- a/tool/tctl/common/bots_command_test.go +++ b/tool/tctl/common/bots_command_test.go @@ -19,6 +19,7 @@ package common import ( + "bytes" "context" "encoding/json" "slices" @@ -30,6 +31,7 @@ import ( "github.com/gravitational/trace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/fieldmaskpb" @@ -44,12 +46,162 @@ import ( "github.com/gravitational/teleport/integration/helpers" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/config" + "github.com/gravitational/teleport/lib/cryptosuites" "github.com/gravitational/teleport/lib/itertools/stream" "github.com/gravitational/teleport/lib/service" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/tbot/config/joinuri" + "github.com/gravitational/teleport/lib/utils/log/logtest" + "github.com/gravitational/teleport/lib/utils/testutils/golden" "github.com/gravitational/teleport/tool/teleport/testenv" ) +func useStaticTemplateData(t *testing.T) func(map[string]any) { + return func(data map[string]any) { + if v, ok := data["join_uri"]; ok { + u, err := joinuri.Parse(v.(string)) + require.NoError(t, err) + u.Address = "localhost:443" + + data["join_uri"] = u.String() + } + + if _, ok := data["addr"]; ok { + data["addr"] = "localhost:443" + } + + // Not worth the plumbing to ensure the table remains consistent, ugh. + if _, ok := data["param_table"]; ok { + data["param_table"] = " Fake: Table\n" + } + } +} + +func TestAddBot(t *testing.T) { + t.Parallel() + + process, err := testenv.NewTeleportProcess(t.TempDir(), testenv.WithLogger(logtest.NewLogger())) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, process.Close()) + require.NoError(t, process.Wait()) + }) + rootClient, err := testenv.NewDefaultAuthClient(process) + require.NoError(t, err) + t.Cleanup(func() { _ = rootClient.Close() }) + + buf := &bytes.Buffer{} + require.NoError(t, (&BotsCommand{ + stdout: buf, + format: teleport.Text, + botName: "test", + botRoles: "access", + registrationSecret: "static-registration-secret", + testStaticToken: "static-example-1234", + testMutateTemplateData: useStaticTemplateData(t), + }).AddBot(t.Context(), rootClient)) + + if golden.ShouldSet() { + golden.Set(t, buf.Bytes()) + } + + require.Equal(t, string(golden.Get(t)), buf.String()) +} + +func TestAddBotLegacy(t *testing.T) { + t.Parallel() + + process, err := testenv.NewTeleportProcess(t.TempDir(), testenv.WithLogger(logtest.NewLogger())) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, process.Close()) + require.NoError(t, process.Wait()) + }) + rootClient, err := testenv.NewDefaultAuthClient(process) + require.NoError(t, err) + t.Cleanup(func() { _ = rootClient.Close() }) + + buf := &bytes.Buffer{} + require.NoError(t, (&BotsCommand{ + stdout: buf, + format: teleport.Text, + botName: "test", + botRoles: "access", + legacy: true, + testStaticToken: "static-example-1234", + testMutateTemplateData: useStaticTemplateData(t), + }).AddBot(t.Context(), rootClient)) + + if golden.ShouldSet() { + golden.Set(t, buf.Bytes()) + } + + require.Equal(t, string(golden.Get(t)), buf.String()) +} + +func TestAddBotJSON(t *testing.T) { + t.Parallel() + + process, err := testenv.NewTeleportProcess(t.TempDir(), testenv.WithLogger(logtest.NewLogger())) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, process.Close()) + require.NoError(t, process.Wait()) + }) + rootClient, err := testenv.NewDefaultAuthClient(process) + require.NoError(t, err) + t.Cleanup(func() { _ = rootClient.Close() }) + + // Generate a public key to test pregenerated keys + key, err := cryptosuites.GenerateKey( + t.Context(), + cryptosuites.StaticAlgorithmSuite(types.SignatureAlgorithmSuite_SIGNATURE_ALGORITHM_SUITE_BALANCED_V1), + cryptosuites.BoundKeypairJoining, + ) + require.NoError(t, err) + + sshPubKey, err := ssh.NewPublicKey(key.Public()) + require.NoError(t, err) + + publicKeyBytes := ssh.MarshalAuthorizedKey(sshPubKey) + publicKeyString := strings.TrimSpace(string(publicKeyBytes)) + + buf := &bytes.Buffer{} + require.NoError(t, (&BotsCommand{ + stdout: buf, + format: teleport.JSON, + botName: "test", + botRoles: "access", + recoveryLimit: 12, + initialPublicKey: publicKeyString, + testStaticToken: "static-example-1234", + }).AddBot(t.Context(), rootClient)) + + // Validate the response + response := botJSONResponse{} + require.NoError(t, json.Unmarshal(buf.Bytes(), &response)) + + require.Empty(t, response.RegistrationSecret) + + uri, err := joinuri.Parse(response.JoinURI) + require.NoError(t, err) + + require.Equal(t, types.JoinMethodBoundKeypair, uri.JoinMethod) + require.Empty(t, uri.JoinMethodParameter) + + // Fetch the token and make sure it's sane + token, err := rootClient.GetToken(t.Context(), response.TokenID) + require.NoError(t, err) + + ptv2, ok := token.(*types.ProvisionTokenV2) + require.True(t, ok) + + require.EqualValues(t, 12, ptv2.Spec.BoundKeypair.Recovery.Limit) + // Note: soft string comparison against a public key, but it should just use our value + require.Equal(t, publicKeyString, ptv2.Spec.BoundKeypair.Onboarding.InitialPublicKey) + require.Empty(t, ptv2.Status.BoundKeypair.RegistrationSecret) +} + func TestUpdateBotLogins(t *testing.T) { tests := []struct { desc string @@ -260,7 +412,7 @@ func TestAddAndListBotInstancesJSON(t *testing.T) { }, }, } - process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors)) + process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors), withEnableProxy()) ctx := context.Background() client, err := testenv.NewDefaultAuthClient(process) require.NoError(t, err) @@ -298,8 +450,15 @@ func TestAddAndListBotInstancesJSON(t *testing.T) { response := botJSONResponse{} require.NoError(t, json.Unmarshal([]byte(buf.String()), &response)) - _, err = client.GetToken(ctx, response.TokenID) + token, err := client.GetToken(ctx, response.TokenID) + require.NoError(t, err) + + // Make sure these are being created with the intended defaults + require.Equal(t, types.JoinMethodBoundKeypair, token.GetJoinMethod()) + uri, err := joinuri.Parse(response.JoinURI) require.NoError(t, err) + require.Equal(t, types.JoinMethodBoundKeypair, uri.JoinMethod) + require.True(t, token.Expiry().IsZero(), "bound keypair token must not expire") // Run the command again to ensure multiple distinct tokens can be created. buf.Reset() @@ -307,13 +466,28 @@ func TestAddAndListBotInstancesJSON(t *testing.T) { response2 := botJSONResponse{} require.NoError(t, json.Unmarshal([]byte(buf.String()), &response2)) - require.NotEqual(t, response.TokenID, response2.TokenID) _, err = client.GetToken(ctx, response2.TokenID) require.NoError(t, err) + // Try once more, but with legacy mode buf.Reset() + cmd.legacy = true + require.NoError(t, cmd.AddBotInstance(ctx, client)) + + response3 := botJSONResponse{} + require.NoError(t, json.Unmarshal([]byte(buf.String()), &response3)) + + token, err = client.GetToken(ctx, response3.TokenID) + require.NoError(t, err) + require.Equal(t, types.JoinMethodToken, token.GetJoinMethod()) + + // We should still include the URI in legacy mode + uri, err = joinuri.Parse(response3.JoinURI) + require.NoError(t, err) + require.Equal(t, types.JoinMethodToken, uri.JoinMethod) + require.False(t, token.Expiry().IsZero(), "traditional token must expire") } func TestAggregateServiceHealth(t *testing.T) { diff --git a/tool/tctl/common/helpers_test.go b/tool/tctl/common/helpers_test.go index 5302b9c5fdc23..69570e7718545 100644 --- a/tool/tctl/common/helpers_test.go +++ b/tool/tctl/common/helpers_test.go @@ -261,6 +261,7 @@ type testServerOptions struct { fileDescriptors []*servicecfg.FileDescriptor fakeClock *clockwork.FakeClock enableCache bool + enableProxy bool } type testServerOptionFunc func(options *testServerOptions) @@ -289,6 +290,12 @@ func withEnableCache(enableCache bool) testServerOptionFunc { } } +func withEnableProxy() testServerOptionFunc { + return func(options *testServerOptions) { + options.enableProxy = true + } +} + func makeAndRunTestAuthServer(t *testing.T, opts ...testServerOptionFunc) (auth *service.TeleportProcess) { var options testServerOptions for _, opt := range opts { @@ -305,6 +312,9 @@ func makeAndRunTestAuthServer(t *testing.T, opts ...testServerOptionFunc) (auth } cfg.CachePolicy.Enabled = options.enableCache + if options.enableProxy { + cfg.Proxy.Enabled = true + } cfg.Proxy.DisableWebInterface = true cfg.InstanceMetadataClient = imds.NewDisabledIMDSClient() if options.fakeClock != nil { diff --git a/tool/tctl/common/testdata/TestAddBot.golden b/tool/tctl/common/testdata/TestAddBot.golden new file mode 100644 index 0000000000000..2a593b1f62f84 --- /dev/null +++ b/tool/tctl/common/testdata/TestAddBot.golden @@ -0,0 +1,33 @@ +The bot joining URI: tbot+proxy+bound-keypair://static-example-1234:static-registration-secret@localhost:443 + +To start a new tbot running the identity service, run: + +> tbot start identity \ + --join-uri=tbot+proxy+bound-keypair://static-example-1234:static-registration-secret@localhost:443 \ + --destination=./destination + +Alternatively, if you'd like to generate a tbot.yaml config file, you can +instead run: + +> tbot configure identity \ + --join-uri=tbot+proxy+bound-keypair://static-example-1234:static-registration-secret@localhost:443 \ + --destination=./destination > tbot.yaml + +Then, run tbot with: + +> tbot start -c tbot.yaml + +Advanced parameters: + Fake: Table + +Please note: + - The ./destination destination directory can be changed as desired. + - /var/lib/teleport/bot must be accessible to the bot user, or --storage + must point to another accessible directory to store internal bot data. + - This example shows only use of the 'identity' service. See our documentation + for all supported service types: + https://goteleport.com/docs/reference/cli/tbot/ + - This token will be permanently bound to a single 'tbot' instance upon first + join. For scalable alternatives, see our documentation on other supported + join methods: + https://goteleport.com/docs/enroll-resources/machine-id/deployment/ diff --git a/tool/tctl/common/testdata/TestAddBotLegacy.golden b/tool/tctl/common/testdata/TestAddBotLegacy.golden new file mode 100644 index 0000000000000..fd58abf81e5d2 --- /dev/null +++ b/tool/tctl/common/testdata/TestAddBotLegacy.golden @@ -0,0 +1,35 @@ + +The bot token: static-example-1234 +The joining URI: tbot+proxy+token://static-example-1234@localhost:443 +This token will expire in 59 minutes. + +Optionally, if running the bot under an isolated user account, first initialize +the data directory by running the following command as root: + +> tbot init \ + --destination-dir=./tbot-user \ + --bot-user=tbot \ + --reader-user=alice + +... where "tbot" is the username of the bot's UNIX user, and "alice" is the +UNIX user that will be making use of the certificates. + +Then, run this as the bot user to begin continuously fetching +certificates: + +> tbot start \ + --destination-dir=./tbot-user \ + --token=static-example-1234 \ + --proxy-server=localhost:443 \ + --join-method=token + +Please note: + + - The ./tbot-user destination directory can be changed as desired. + - /var/lib/teleport/bot must be accessible to the bot user, or --data-dir + must point to another accessible directory to store internal bot data. + - This invitation token will expire in 59 minutes + - localhost:443 must be reachable from the new node + - This is a single-token that will be consumed upon usage. For scalable + alternatives, see our documentation on other supported join methods: + https://goteleport.com/docs/enroll-resources/machine-id/deployment/