From 1dcdb066ce2ec3519f93857ebe2b871509f8dd70 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Mon, 2 Feb 2026 22:07:30 -0700 Subject: [PATCH 01/10] 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`. --- 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 | 128 +--------- lib/tbot/config/uri_test.go | 78 +----- lib/tbot/tbot.go | 5 +- tool/tbot/main.go | 3 +- tool/tctl/common/bots_command.go | 352 +++++++++++++++++++++++++--- 8 files changed, 653 insertions(+), 229 deletions(-) create mode 100644 lib/tbot/config/joinuri/uri.go create mode 100644 lib/tbot/config/joinuri/uri_test.go 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..22ecc46188e9a 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,13 @@ 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" -) - -const ( - // URISchemePrefix is the prefix for - URISchemePrefix = "tbot" + joinuri "github.com/gravitational/teleport/lib/tbot/config/joinuri" + "github.com/gravitational/trace" ) -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 +50,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 +58,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 +102,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..0c223ecc17b6f 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,12 @@ 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" + "github.com/stretchr/testify/require" ) -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 +156,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..e5d1ae6fdcd26 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,17 @@ 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 allowedLogins []string addLogins string @@ -104,14 +111,36 @@ 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 Machine & Workload Identity 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( + "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) 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.botsAdd.Flag("legacy", "If set, generate a legacy joining token instead of a bound keypair token. No effect if --token is set.").BoolVar(&c.legacy) + c.botsAdd.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) + c.botsAdd.Flag( + "recovery-mode", + "If set, use the given recovery mode for the bound keypair token. No effect if --token or --legacy is set.", + ).Default(string(boundkeypair.RecoveryModeStandard)).EnumVar(&c.recoveryMode, boundkeypair.RecoveryModeStrings()...) + c.botsAdd.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) 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) @@ -147,6 +176,29 @@ func (c *BotsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIF 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.botsInstancesAdd.Flag("legacy", "If set, generate a legacy joining token instead of a bound keypair token. No effect if --token is set.").BoolVar(&c.legacy) + c.botsInstancesAdd.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) + c.botsInstancesAdd.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) + c.botsInstancesAdd.Flag( + "recovery-mode", + "If set, use the given recovery mode for the bound keypair token. No effect if --token or --legacy is set.", + ).Default(string(boundkeypair.RecoveryModeStandard)).EnumVar(&c.recoveryMode, boundkeypair.RecoveryModeStrings()...) + c.botsInstancesAdd.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) 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,82 @@ 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 }} + +> tbot start identity \ + --join-uri={{ .join_uri }} \ + --destination=./destination + +Full 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, + }, + Recovery: &types.ProvisionTokenSpecV2BoundKeypair_RecoverySpec{ + Mode: c.recoveryMode, + Limit: recoveryLimit, + }, + }, + } + + token, err := 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,7 +434,13 @@ 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 == "" { + 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 tokenName, err := utils.CryptoRandomHex(defaults.TokenLenBytes) if err != nil { @@ -324,7 +462,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 +512,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(outputToken(ctx, c.stdout, c.format, c.legacy, client, bot, token)) } func (c *BotsCommand) RemoveBot(ctx context.Context, client botsCommandClient) error { @@ -718,6 +856,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 +871,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(outputToken(ctx, c.stdout, c.format, false, 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 +890,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 +898,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(outputToken(ctx, c.stdout, c.format, true, client, bot, token)) } // There's not much to do in this case, but we can validate the token. @@ -756,7 +906,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 +923,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(outputToken(ctx, c.stdout, c.format, false, client, bot, token)) } var showMessageTemplate = template.Must(template.New("show").Funcs(template.FuncMap{ @@ -859,18 +1009,49 @@ 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 { +func outputToken( + ctx context.Context, + wr io.Writer, + format string, + legacy bool, + 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 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,7 +1061,17 @@ 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") @@ -890,32 +1081,73 @@ func outputToken(ctx context.Context, wr io.Writer, format string, client botsCo 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 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()) + } + return startMessageTemplate.Execute(wr, 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()) } - return startMessageTemplate.Execute(wr, templateData) + return startMessageTemplateV2.Execute(wr, 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 +1311,47 @@ 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. +func createUniqueBotToken(ctx context.Context, client botsCommandClient, ttl time.Duration, spec types.ProvisionTokenSpecV2) (types.ProvisionToken, error) { + for i := 0; i < 3; i++ { + 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") +} From 75e230046e9b7362e7a6c9b146120fdc198c640f Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Tue, 3 Feb 2026 18:55:08 -0700 Subject: [PATCH 02/10] 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. --- tool/tctl/common/bots_command_test.go | 2 +- tool/tctl/common/helpers_test.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/tool/tctl/common/bots_command_test.go b/tool/tctl/common/bots_command_test.go index c0febbc745f0d..afd6a2a0f5c8e 100644 --- a/tool/tctl/common/bots_command_test.go +++ b/tool/tctl/common/bots_command_test.go @@ -260,7 +260,7 @@ func TestAddAndListBotInstancesJSON(t *testing.T) { }, }, } - process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors)) + process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors), withEnableProxy(true)) ctx := context.Background() client, err := testenv.NewDefaultAuthClient(process) require.NoError(t, err) diff --git a/tool/tctl/common/helpers_test.go b/tool/tctl/common/helpers_test.go index 5302b9c5fdc23..a6c9a20481ee4 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(enableProxy bool) testServerOptionFunc { + return func(options *testServerOptions) { + options.enableProxy = enableProxy + } +} + func makeAndRunTestAuthServer(t *testing.T, opts ...testServerOptionFunc) (auth *service.TeleportProcess) { var options testServerOptions for _, opt := range opts { @@ -305,6 +312,7 @@ func makeAndRunTestAuthServer(t *testing.T, opts ...testServerOptionFunc) (auth } cfg.CachePolicy.Enabled = options.enableCache + cfg.Proxy.Enabled = options.enableProxy cfg.Proxy.DisableWebInterface = true cfg.InstanceMetadataClient = imds.NewDisabledIMDSClient() if options.fakeClock != nil { From 641ada00bb682c437985c7820fdee3157a866cf4 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Tue, 3 Feb 2026 18:58:47 -0700 Subject: [PATCH 03/10] Fix imports --- lib/tbot/config/uri.go | 3 ++- lib/tbot/config/uri_test.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/tbot/config/uri.go b/lib/tbot/config/uri.go index 22ecc46188e9a..f30f6f1e04ace 100644 --- a/lib/tbot/config/uri.go +++ b/lib/tbot/config/uri.go @@ -22,10 +22,11 @@ import ( "context" "log/slog" + "github.com/gravitational/trace" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/tbot/bot/connection" joinuri "github.com/gravitational/teleport/lib/tbot/config/joinuri" - "github.com/gravitational/trace" ) // applyValueOrError sets the target `target` to the value `value`, but only if diff --git a/lib/tbot/config/uri_test.go b/lib/tbot/config/uri_test.go index 0c223ecc17b6f..a16b795fb6bc4 100644 --- a/lib/tbot/config/uri_test.go +++ b/lib/tbot/config/uri_test.go @@ -21,10 +21,11 @@ package config import ( "testing" + "github.com/stretchr/testify/require" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/tbot/bot/onboarding" "github.com/gravitational/teleport/lib/tbot/config/joinuri" - "github.com/stretchr/testify/require" ) func TestJoinURIApplyToConfig(t *testing.T) { From 0bf9c6abfd411ee3d81bb6e3802cc99c319a8b71 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Tue, 3 Feb 2026 20:05:10 -0700 Subject: [PATCH 04/10] 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. --- tool/tctl/common/helpers_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tool/tctl/common/helpers_test.go b/tool/tctl/common/helpers_test.go index a6c9a20481ee4..69570e7718545 100644 --- a/tool/tctl/common/helpers_test.go +++ b/tool/tctl/common/helpers_test.go @@ -290,9 +290,9 @@ func withEnableCache(enableCache bool) testServerOptionFunc { } } -func withEnableProxy(enableProxy bool) testServerOptionFunc { +func withEnableProxy() testServerOptionFunc { return func(options *testServerOptions) { - options.enableProxy = enableProxy + options.enableProxy = true } } @@ -312,7 +312,9 @@ func makeAndRunTestAuthServer(t *testing.T, opts ...testServerOptionFunc) (auth } cfg.CachePolicy.Enabled = options.enableCache - cfg.Proxy.Enabled = options.enableProxy + if options.enableProxy { + cfg.Proxy.Enabled = true + } cfg.Proxy.DisableWebInterface = true cfg.InstanceMetadataClient = imds.NewDisabledIMDSClient() if options.fakeClock != nil { From 1aaa66a1d1f67c5ca614299d9238a9787c207dc4 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Tue, 3 Feb 2026 20:07:16 -0700 Subject: [PATCH 05/10] Add test coverage for new bot templates --- tool/tctl/common/bots_command_test.go | 155 +++++++++++++++++- tool/tctl/common/testdata/TestAddBot.golden | 24 +++ .../common/testdata/TestAddBotLegacy.golden | 35 ++++ 3 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 tool/tctl/common/testdata/TestAddBot.golden create mode 100644 tool/tctl/common/testdata/TestAddBotLegacy.golden diff --git a/tool/tctl/common/bots_command_test.go b/tool/tctl/common/bots_command_test.go index afd6a2a0f5c8e..8340b8a5f338b 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,135 @@ 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 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", + }).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, + }).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, + }).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 +385,7 @@ func TestAddAndListBotInstancesJSON(t *testing.T) { }, }, } - process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors), withEnableProxy(true)) + process := makeAndRunTestAuthServer(t, withFileConfig(fileConfig), withFileDescriptors(dynAddr.Descriptors), withEnableProxy()) ctx := context.Background() client, err := testenv.NewDefaultAuthClient(process) require.NoError(t, err) @@ -298,22 +423,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) + token, 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/testdata/TestAddBot.golden b/tool/tctl/common/testdata/TestAddBot.golden new file mode 100644 index 0000000000000..f4823f343a8a6 --- /dev/null +++ b/tool/tctl/common/testdata/TestAddBot.golden @@ -0,0 +1,24 @@ +The bot joining URI: tbot+proxy+bound-keypair://bot-test-31792a52:38cf6b9192b5a2cc2e3c7bde9f24d190@127.0.0.1:63947 + +> tbot start identity \ + --join-uri=tbot+proxy+bound-keypair://bot-test-31792a52:38cf6b9192b5a2cc2e3c7bde9f24d190@127.0.0.1:63947 \ + --destination=./destination + +Full parameters: + Proxy: 127.0.0.1:63947 + Token: bot-test-31792a52 + Join Method: bound_keypair + Registration Secret: 38cf6b9192b5a2cc2e3c7bde9f24d190 + + +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..8887b34a45c95 --- /dev/null +++ b/tool/tctl/common/testdata/TestAddBotLegacy.golden @@ -0,0 +1,35 @@ + +The bot token: 9e0fd583236301b7d683c989e674ab0e +The joining URI: tbot+proxy+token://9e0fd583236301b7d683c989e674ab0e@127.0.0.1:63957 +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=9e0fd583236301b7d683c989e674ab0e \ + --proxy-server=127.0.0.1:63957 \ + --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 + - 127.0.0.1:63957 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/ From bca3159a368acdc80b188c8dbe39506db5d8df28 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Tue, 3 Feb 2026 20:17:28 -0700 Subject: [PATCH 06/10] Fix lint --- tool/tctl/common/bots_command_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/tctl/common/bots_command_test.go b/tool/tctl/common/bots_command_test.go index 8340b8a5f338b..f664d6f428f86 100644 --- a/tool/tctl/common/bots_command_test.go +++ b/tool/tctl/common/bots_command_test.go @@ -441,7 +441,7 @@ func TestAddAndListBotInstancesJSON(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(buf.String()), &response2)) require.NotEqual(t, response.TokenID, response2.TokenID) - token, err = client.GetToken(ctx, response2.TokenID) + _, err = client.GetToken(ctx, response2.TokenID) require.NoError(t, err) // Try once more, but with legacy mode From 32bcdddba6f96e1989772ee91d0086ea87d90813 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Tue, 3 Feb 2026 21:14:21 -0700 Subject: [PATCH 07/10] 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. --- tool/tctl/common/bots_command.go | 114 ++++++++++++------ tool/tctl/common/bots_command_test.go | 45 +++++-- tool/tctl/common/testdata/TestAddBot.golden | 10 +- .../common/testdata/TestAddBotLegacy.golden | 10 +- 4 files changed, 123 insertions(+), 56 deletions(-) diff --git a/tool/tctl/common/bots_command.go b/tool/tctl/common/bots_command.go index e5d1ae6fdcd26..06f5791cf376e 100644 --- a/tool/tctl/common/bots_command.go +++ b/tool/tctl/common/bots_command.go @@ -69,17 +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 - legacy bool - initialPublicKey string - recoveryMode string - recoveryLimit uint32 + 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 @@ -141,6 +150,12 @@ func (c *BotsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIF "token. No effect if --token or --legacy is set, or if "+ "--recovery-mode is not standard. Must be greater than 1.", ).Uint32Var(&c.recoveryLimit) + c.botsAdd.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) 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) @@ -199,6 +214,12 @@ func (c *BotsCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIF "token. No effect if --token or --legacy is set, or if "+ "--recovery-mode is not standard. Must be greater than 1.", ).Uint32Var(&c.recoveryLimit) + c.botsInstancesAdd.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) if c.stdout == nil { c.stdout = os.Stdout @@ -355,7 +376,6 @@ This token will expire in {{ .minutes }} minutes.{{ end }} Full 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 @@ -403,6 +423,7 @@ func (c *BotsCommand) createBoundKeypairBotToken(ctx context.Context, client bot Onboarding: &types.ProvisionTokenSpecV2BoundKeypair_OnboardingSpec{ InitialPublicKey: initialPublicKey, MustRegisterBefore: mustRegisterBefore, + RegistrationSecret: c.registrationSecret, }, Recovery: &types.ProvisionTokenSpecV2BoundKeypair_RecoverySpec{ Mode: c.recoveryMode, @@ -411,7 +432,7 @@ func (c *BotsCommand) createBoundKeypairBotToken(ctx context.Context, client bot }, } - token, err := createUniqueBotToken(ctx, client, 0, spec) + token, err := c.createUniqueBotToken(ctx, client, 0, spec) if err != nil { return nil, trace.Wrap(err, "creating join token") } @@ -442,9 +463,14 @@ func (c *BotsCommand) AddBot(ctx context.Context, client botsCommandClient) erro } case c.tokenID == "" && c.legacy: // If there's no token specified, generate one - tokenName, err := utils.CryptoRandomHex(defaults.TokenLenBytes) - if err != nil { - return trace.Wrap(err) + 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 { @@ -512,7 +538,7 @@ func (c *BotsCommand) AddBot(ctx context.Context, client botsCommandClient) erro return trace.Wrap(err) } - return trace.Wrap(outputToken(ctx, c.stdout, c.format, c.legacy, client, bot, token)) + return trace.Wrap(c.outputToken(ctx, client, bot, token)) } func (c *BotsCommand) RemoveBot(ctx context.Context, client botsCommandClient) error { @@ -877,7 +903,7 @@ func (c *BotsCommand) AddBotInstance(ctx context.Context, client botsCommandClie return trace.Wrap(err) } - return trace.Wrap(outputToken(ctx, c.stdout, c.format, false, client, bot, token)) + 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) @@ -898,7 +924,7 @@ func (c *BotsCommand) AddBotInstance(ctx context.Context, client botsCommandClie return trace.Wrap(err) } - return trace.Wrap(outputToken(ctx, c.stdout, c.format, true, 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. @@ -923,7 +949,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, false, client, bot, token)) + return trace.Wrap(c.outputToken(ctx, client, bot, token)) } var showMessageTemplate = template.Must(template.New("show").Funcs(template.FuncMap{ @@ -1018,11 +1044,8 @@ type botJSONResponse struct { } // outputToken writes token information to stdout, depending on the token format. -func outputToken( +func (c *BotsCommand) outputToken( ctx context.Context, - wr io.Writer, - format string, - legacy bool, client botsCommandClient, bot *machineidv1pb.Bot, token types.ProvisionToken, @@ -1046,7 +1069,7 @@ func outputToken( secret, _ := uri.ToURL().User.Password() - if format == teleport.JSON { + if c.format == teleport.JSON { tokenTTL := time.Duration(0) if exp := token.Expiry(); !exp.IsZero() { tokenTTL = time.Until(exp) @@ -1077,11 +1100,11 @@ func outputToken( return trace.Wrap(err, "failed to marshal CreateBot response") } - fmt.Fprintln(wr, string(out)) + fmt.Fprintln(c.stdout, string(out)) return nil } - if legacy { + if c.legacy { joinMethod := token.GetJoinMethod() if joinMethod == types.JoinMethodUnspecified { joinMethod = types.JoinMethodToken @@ -1098,7 +1121,12 @@ func outputToken( } 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 startMessageTemplate.Execute(c.stdout, templateData) } joinMethod := token.GetJoinMethod() @@ -1124,7 +1152,12 @@ func outputToken( } else if deadline := getBoundKeypairRegistrationDeadline(token); deadline != nil { templateData["minutes"] = int(time.Until(*deadline).Minutes()) } - return startMessageTemplateV2.Execute(wr, templateData) + + if c.testMutateTemplateData != nil { + c.testMutateTemplateData(templateData) + } + + return startMessageTemplateV2.Execute(c.stdout, templateData) } func getBoundKeypairRegistrationDeadline(token types.ProvisionToken) *time.Time { @@ -1315,15 +1348,26 @@ func aggregateServiceHealth(services []*machineidv1pb.BotInstanceServiceHealth) // 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. -func createUniqueBotToken(ctx context.Context, client botsCommandClient, ttl time.Duration, spec types.ProvisionTokenSpecV2) (types.ProvisionToken, error) { +// 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++ { - suffix, err := utils.CryptoRandomHex(4) - if err != nil { - return nil, trace.Wrap(err) + 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) } - 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) diff --git a/tool/tctl/common/bots_command_test.go b/tool/tctl/common/bots_command_test.go index f664d6f428f86..26c43e5a81749 100644 --- a/tool/tctl/common/bots_command_test.go +++ b/tool/tctl/common/bots_command_test.go @@ -56,6 +56,27 @@ import ( "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() @@ -71,10 +92,13 @@ func TestAddBot(t *testing.T) { buf := &bytes.Buffer{} require.NoError(t, (&BotsCommand{ - stdout: buf, - format: teleport.Text, - botName: "test", - botRoles: "access", + 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() { @@ -99,11 +123,13 @@ func TestAddBotLegacy(t *testing.T) { buf := &bytes.Buffer{} require.NoError(t, (&BotsCommand{ - stdout: buf, - format: teleport.Text, - botName: "test", - botRoles: "access", - legacy: true, + 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() { @@ -148,6 +174,7 @@ func TestAddBotJSON(t *testing.T) { botRoles: "access", recoveryLimit: 12, initialPublicKey: publicKeyString, + testStaticToken: "static-example-1234", }).AddBot(t.Context(), rootClient)) // Validate the response diff --git a/tool/tctl/common/testdata/TestAddBot.golden b/tool/tctl/common/testdata/TestAddBot.golden index f4823f343a8a6..87221ef958869 100644 --- a/tool/tctl/common/testdata/TestAddBot.golden +++ b/tool/tctl/common/testdata/TestAddBot.golden @@ -1,15 +1,11 @@ -The bot joining URI: tbot+proxy+bound-keypair://bot-test-31792a52:38cf6b9192b5a2cc2e3c7bde9f24d190@127.0.0.1:63947 +The bot joining URI: tbot+proxy+bound-keypair://static-example-1234:static-registration-secret@localhost:443 > tbot start identity \ - --join-uri=tbot+proxy+bound-keypair://bot-test-31792a52:38cf6b9192b5a2cc2e3c7bde9f24d190@127.0.0.1:63947 \ + --join-uri=tbot+proxy+bound-keypair://static-example-1234:static-registration-secret@localhost:443 \ --destination=./destination Full parameters: - Proxy: 127.0.0.1:63947 - Token: bot-test-31792a52 - Join Method: bound_keypair - Registration Secret: 38cf6b9192b5a2cc2e3c7bde9f24d190 - + Fake: Table Please note: - The ./destination destination directory can be changed as desired. diff --git a/tool/tctl/common/testdata/TestAddBotLegacy.golden b/tool/tctl/common/testdata/TestAddBotLegacy.golden index 8887b34a45c95..fd58abf81e5d2 100644 --- a/tool/tctl/common/testdata/TestAddBotLegacy.golden +++ b/tool/tctl/common/testdata/TestAddBotLegacy.golden @@ -1,6 +1,6 @@ -The bot token: 9e0fd583236301b7d683c989e674ab0e -The joining URI: tbot+proxy+token://9e0fd583236301b7d683c989e674ab0e@127.0.0.1:63957 +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 @@ -19,8 +19,8 @@ certificates: > tbot start \ --destination-dir=./tbot-user \ - --token=9e0fd583236301b7d683c989e674ab0e \ - --proxy-server=127.0.0.1:63957 \ + --token=static-example-1234 \ + --proxy-server=localhost:443 \ --join-method=token Please note: @@ -29,7 +29,7 @@ Please note: - /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 - - 127.0.0.1:63957 must be reachable from the new node + - 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/ From c1a1089cd792ae96f0edcb3f08a623f711e08bbe Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Thu, 5 Feb 2026 18:47:09 -0700 Subject: [PATCH 08/10] Dedupe bot token flags Moves bot token flag initialization into a shared `initSharedBotTokenFlags()` helper. --- tool/tctl/common/bots_command.go | 81 ++++++++++++-------------------- 1 file changed, 29 insertions(+), 52 deletions(-) diff --git a/tool/tctl/common/bots_command.go b/tool/tctl/common/bots_command.go index 06f5791cf376e..422a616729302 100644 --- a/tool/tctl/common/bots_command.go +++ b/tool/tctl/common/bots_command.go @@ -113,49 +113,56 @@ type BotsCommand struct { stdout io.Writer } -// 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") - - 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 Machine & Workload Identity 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( +// 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) + 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) - 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.botsAdd.Flag("legacy", "If set, generate a legacy joining token instead of a bound keypair token. No effect if --token is set.").BoolVar(&c.legacy) - c.botsAdd.Flag( + 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) - c.botsAdd.Flag( + cmd.Flag( "recovery-mode", - "If set, use the given recovery mode for the bound keypair token. No effect if --token or --legacy is set.", + "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()...) - c.botsAdd.Flag( + 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) - c.botsAdd.Flag( + 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") + + 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 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("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) @@ -189,37 +196,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.botsInstancesAdd.Flag("legacy", "If set, generate a legacy joining token instead of a bound keypair token. No effect if --token is set.").BoolVar(&c.legacy) - c.botsInstancesAdd.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) - c.botsInstancesAdd.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) - c.botsInstancesAdd.Flag( - "recovery-mode", - "If set, use the given recovery mode for the bound keypair token. No effect if --token or --legacy is set.", - ).Default(string(boundkeypair.RecoveryModeStandard)).EnumVar(&c.recoveryMode, boundkeypair.RecoveryModeStrings()...) - c.botsInstancesAdd.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) - c.botsInstancesAdd.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) + c.initSharedBotTokenFlags(c.botsInstancesAdd) if c.stdout == nil { c.stdout = os.Stdout From 37eb05b9e84f27662e9e80656d18d861256d45f2 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Thu, 5 Feb 2026 19:42:54 -0700 Subject: [PATCH 09/10] Add removal TODO for v20 --- tool/tctl/common/bots_command.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tool/tctl/common/bots_command.go b/tool/tctl/common/bots_command.go index 422a616729302..8913d8d0eb767 100644 --- a/tool/tctl/common/bots_command.go +++ b/tool/tctl/common/bots_command.go @@ -118,6 +118,8 @@ type BotsCommand struct { 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", From 3debabb3ba79ef9e27f3ed5f170eba88e2779f58 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Fri, 6 Feb 2026 19:43:36 -0700 Subject: [PATCH 10/10] Add 'configure' instructions to the bot template --- tool/tctl/common/bots_command.go | 15 ++++++++++++++- tool/tctl/common/testdata/TestAddBot.golden | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/tool/tctl/common/bots_command.go b/tool/tctl/common/bots_command.go index 8913d8d0eb767..5fe9be62f2c72 100644 --- a/tool/tctl/common/bots_command.go +++ b/tool/tctl/common/bots_command.go @@ -349,11 +349,24 @@ var startMessageTemplateV2 = template.Must(template.New( 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 -Full parameters: +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. diff --git a/tool/tctl/common/testdata/TestAddBot.golden b/tool/tctl/common/testdata/TestAddBot.golden index 87221ef958869..2a593b1f62f84 100644 --- a/tool/tctl/common/testdata/TestAddBot.golden +++ b/tool/tctl/common/testdata/TestAddBot.golden @@ -1,10 +1,23 @@ 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 -Full parameters: +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: