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