From bec78b14c8156ce9404970a0fa749839dbadb2cf Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Mon, 9 Feb 2026 16:32:04 -0700 Subject: [PATCH] MWI: Default to creating bound keypair tokens for bots in `tctl` (#63422) * MWI: Default to creating bound keypair tokens for bots in `tctl` This adjusts the default behavior in `tctl` to create bound keypair tokens when creating new bot tokens in `tctl bots add` and `tctl bots instances add`. This adds a new "V2" message template intended for use with joining URIs and bound keypair tokens, and defaults to that. The existing template is still available, with minor additive changes, using the `--legacy` flag. Additional flags to manage bound keypair token parameters (recovery mode, etc) were also added and are specific to this join method. Additionally, joining URI parsing utilities were moved into their own package to be importable from `tctl` without importing all of `lib/tbot`. * Fix broken TestAddAndListBotInstancesJSON A slight control flow change introduced a dependency on a working proxy, which isn't enabled by tctl's test harness. This enables the proxy for this test, with a new test flag to support this. * Fix imports * Fix regression in TestTokens The new helper unconditionally overrode cfg.Proxy.Enabled, which broke other tests that overrode the parameter in other ways. It now only toggles it on explicitly. * Add test coverage for new bot templates * Fix lint * Deflake AddBot* tests with ugly static plumbing There's a lot of random parameters in the rendered token templates which caused failures in the golden-style tests. This plumbs through a few static parameters along with adding a template parameter mutator to override some parameters to ensure test consistency. * Dedupe bot token flags Moves bot token flag initialization into a shared `initSharedBotTokenFlags()` helper. * Add removal TODO for v20 * Add 'configure' instructions to the bot template --- lib/boundkeypair/bound_keypair.go | 10 + lib/tbot/config/joinuri/uri.go | 210 +++++++++ lib/tbot/config/joinuri/uri_test.go | 96 ++++ lib/tbot/config/uri.go | 125 +---- lib/tbot/config/uri_test.go | 75 +-- lib/tbot/tbot.go | 5 +- tool/tbot/main.go | 3 +- tool/tctl/common/bots_command.go | 426 +++++++++++++++--- tool/tctl/common/bots_command_test.go | 180 +++++++- tool/tctl/common/helpers_test.go | 10 + tool/tctl/common/testdata/TestAddBot.golden | 33 ++ .../common/testdata/TestAddBotLegacy.golden | 35 ++ 12 files changed, 961 insertions(+), 247 deletions(-) create mode 100644 lib/tbot/config/joinuri/uri.go create mode 100644 lib/tbot/config/joinuri/uri_test.go create mode 100644 tool/tctl/common/testdata/TestAddBot.golden create mode 100644 tool/tctl/common/testdata/TestAddBotLegacy.golden diff --git a/lib/boundkeypair/bound_keypair.go b/lib/boundkeypair/bound_keypair.go index e35c10f8f9f..2841f41aa13 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 00000000000..d88f491a48d --- /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 00000000000..fa81c1f3afa --- /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 42c106b3adf..f30f6f1e04a 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 e3e65f0926d..a16b795fb6b 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 0229419b009..fe6c298957b 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 c55f18cb331..26bf7a116de 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" @@ -369,7 +370,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 ba62e06a756..5fe9be62f2c 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,37 +1027,21 @@ 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 { - tokenTTL := time.Duration(0) - if exp := token.Expiry(); !exp.IsZero() { - tokenTTL = time.Until(exp) - } - // 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 - // breaking customer scripts. - response := botJSONResponse{ - UserName: bot.Status.UserName, - RoleName: bot.Status.RoleName, - TokenID: token.GetName(), - TokenTTL: tokenTTL, - } - out, err := json.MarshalIndent(response, "", " ") - if err != nil { - return trace.Wrap(err, "failed to marshal CreateBot response") - } - - fmt.Fprintln(wr, string(out)) - return nil - } - +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() @@ -902,20 +1054,125 @@ func outputToken(ctx context.Context, wr io.Writer, format string, client botsCo } 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 + // breaking customer scripts. + response := botJSONResponse{ + UserName: bot.Status.UserName, + 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(c.stdout, string(out)) + return nil + } + + 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) + } + 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()) } - return startMessageTemplate.Execute(wr, templateData) + + 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 + } + + 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 c0febbc745f..26c43e5a817 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,22 +450,44 @@ 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() require.NoError(t, cmd.AddBotInstance(ctx, client)) 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 5302b9c5fdc..69570e77185 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 00000000000..2a593b1f62f --- /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 00000000000..fd58abf81e5 --- /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/