diff --git a/lib/tbot/cli/start_shared.go b/lib/tbot/cli/start_shared.go
index c7eec049e1115..34eae6b5a246f 100644
--- a/lib/tbot/cli/start_shared.go
+++ b/lib/tbot/cli/start_shared.go
@@ -106,6 +106,7 @@ func (a *AuthProxyArgs) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error
type sharedStartArgs struct {
*AuthProxyArgs
+ JoiningURI string
JoinMethod string
Token string
CAPins []string
@@ -130,7 +131,6 @@ func newSharedStartArgs(cmd *kingpin.CmdClause) *sharedStartArgs {
"(%s)",
strings.Join(config.SupportedJoinMethods, ", "),
)
-
cmd.Flag("token", "A bot join token or path to file with token value, if attempting to onboard a new bot; used on first connect.").Envar(TokenEnvVar).StringVar(&args.Token)
cmd.Flag("ca-pin", "CA pin to validate the Teleport Auth Server; used on first connect.").StringsVar(&args.CAPins)
cmd.Flag("certificate-ttl", "TTL of short-lived machine certificates.").DurationVar(&args.CertificateTTL)
@@ -140,6 +140,7 @@ func newSharedStartArgs(cmd *kingpin.CmdClause) *sharedStartArgs {
cmd.Flag("diag-addr", "If set and the bot is in debug mode, a diagnostics service will listen on specified address.").StringVar(&args.DiagAddr)
cmd.Flag("storage", "A destination URI for tbot's internal storage, e.g. file:///foo/bar").StringVar(&args.Storage)
cmd.Flag("registration-secret", "For bound keypair joining, specifies a registration secret for use at first join.").StringVar(&args.RegistrationSecret)
+ cmd.Flag("join-uri", "An optional URI with joining and authentication parameters. Individual flags for proxy, join method, token, etc may be used instead.").StringVar(&args.JoiningURI)
return args
}
@@ -147,6 +148,10 @@ func newSharedStartArgs(cmd *kingpin.CmdClause) *sharedStartArgs {
func (s *sharedStartArgs) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
// Note: Debug, FIPS, and Insecure are included from globals.
+ if s.JoiningURI != "" {
+ cfg.JoinURI = s.JoiningURI
+ }
+
if s.AuthProxyArgs != nil {
if err := s.AuthProxyArgs.ApplyConfig(cfg, l); err != nil {
return trace.Wrap(err)
diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go
index 7db46e76c1ab2..ab905a0a3da43 100644
--- a/lib/tbot/config/config.go
+++ b/lib/tbot/config/config.go
@@ -219,6 +219,11 @@ type BotConfig struct {
// such as tctl or the Kubernetes operator.
AuthServerAddressMode AuthServerAddressMode `yaml:"-"`
+ // JoinURI is a joining URI, used to supply connection and authentication
+ // parameters in a single bundle. If set, the value is parsed and merged on
+ // top of the existing configuration during `CheckAndSetDefaults()`.
+ JoinURI string `yaml:"join_uri,omitempty"`
+
CredentialLifetime CredentialLifetime `yaml:",inline"`
Oneshot bool `yaml:"oneshot"`
// FIPS instructs `tbot` to run in a mode designed to comply with FIPS
diff --git a/lib/tbot/config/uri.go b/lib/tbot/config/uri.go
new file mode 100644
index 0000000000000..1cf3bec2fdcb3
--- /dev/null
+++ b/lib/tbot/config/uri.go
@@ -0,0 +1,211 @@
+/*
+ * 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 config
+
+import (
+ "context"
+ "log/slog"
+ "net/url"
+ "slices"
+ "strings"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/api/types"
+)
+
+const (
+ // URISchemePrefix is the prefix for
+ URISchemePrefix = "tbot"
+)
+
+type JoinURIParams struct {
+ // AddressKind is the type of joining address, i.e. proxy or auth.
+ AddressKind 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
+// string and arguments. This can be used to ensure existing values will not be
+// overwritten.
+func applyValueOrError[T comparable](target *T, value T, errMsg string, errArgs ...any) error {
+ var zero T
+ switch *target {
+ case zero:
+ *target = value
+ return nil
+ case value:
+ return nil
+ }
+
+ return trace.BadParameter(errMsg, errArgs...)
+}
+
+// ApplyToConfig applies parameters from a parsed joining URI to the given bot
+// 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 {
+ var errors []error
+
+ if cfg.AuthServer != "" {
+ errors = append(errors, trace.BadParameter("URI conflicts with configured field: auth_server"))
+ } else if cfg.ProxyServer != "" {
+ errors = append(errors, trace.BadParameter("URI conflicts with configured field: proxy_server"))
+ } else {
+ switch p.AddressKind {
+ case AddressKindAuth:
+ cfg.AuthServer = p.Address
+ default:
+ // this parameter should not be unspecified due to checks in
+ // ParseJoinURI, so we'll assume proxy.
+ cfg.ProxyServer = p.Address
+ }
+ }
+
+ errors = append(errors, applyValueOrError(
+ &cfg.Onboarding.JoinMethod, p.JoinMethod,
+ "URI joining method %q conflicts with configured field: onboarding.join_method", p.JoinMethod))
+
+ if cfg.Onboarding.TokenValue != "" {
+ errors = append(errors, trace.BadParameter("URI conflicts with configured field: onboarding.token"))
+ } else {
+ cfg.Onboarding.SetToken(p.Token)
+ }
+
+ // The join method parameter maps to a method-specific field when set.
+ if param := p.JoinMethodParameter; param != "" {
+ switch p.JoinMethod {
+ case types.JoinMethodAzure:
+ errors = append(errors, applyValueOrError(
+ &cfg.Onboarding.Azure.ClientID, param,
+ "URI join method parameter %q conflicts with configured field: onboarding.azure.client_id",
+ param))
+ case types.JoinMethodTerraformCloud:
+ errors = append(errors, applyValueOrError(
+ &cfg.Onboarding.Terraform.AudienceTag, param,
+ "URI join method parameter %q conflicts with configured field: onboarding.terraform.audience_tag", param))
+ case types.JoinMethodGitLab:
+ errors = append(errors, applyValueOrError(
+ &cfg.Onboarding.Gitlab.TokenEnvVarName, param,
+ "URI join method parameter %q conflicts with configured field: onboarding.gitlab.token_env_var_name", param))
+ case types.JoinMethodBoundKeypair:
+ errors = append(errors, applyValueOrError(
+ &cfg.Onboarding.BoundKeypair.RegistrationSecret, param,
+ "URI join method parameter %q conflicts with configured field: onboarding.bound_keypair.initial_join_secret", param))
+ default:
+ slog.WarnContext(
+ context.Background(),
+ "ignoring join method parameter for unsupported join method",
+ "join_method", p.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(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 AddressKind
+ switch schemeParts[1] {
+ case string(AddressKindProxy):
+ kind = AddressKindProxy
+ case string(AddressKindAuth):
+ kind = AddressKindAuth
+ default:
+ return nil, trace.BadParameter(
+ "unsupported joining URI scheme %q: address kind must be one of [%q, %q], got: %q",
+ uri.Scheme, AddressKindProxy, 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
new file mode 100644
index 0000000000000..d57a0e09a98e1
--- /dev/null
+++ b/lib/tbot/config/uri_test.go
@@ -0,0 +1,237 @@
+/*
+ * 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 config
+
+import (
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/api/types"
+)
+
+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: AddressKindProxy,
+ Token: "asdf",
+ JoinMethod: types.JoinMethodToken,
+ Address: "example.com:1234",
+ JoinMethodParameter: "",
+ },
+ },
+ {
+ uri: "tbot+auth+bound-keypair://token:param@example.com",
+ expect: &JoinURIParams{
+ AddressKind: 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
+ inputConfig *BotConfig
+ expectConfig *BotConfig
+ expectError require.ErrorAssertionFunc
+ }{
+ {
+ uri: "tbot+proxy+token://asdf@example.com:1234",
+ inputConfig: &BotConfig{},
+ expectConfig: &BotConfig{
+ Onboarding: OnboardingConfig{
+ TokenValue: "asdf",
+ JoinMethod: types.JoinMethodToken,
+ },
+ ProxyServer: "example.com:1234",
+ },
+ },
+ {
+ uri: "tbot+proxy+bound-keypair://some-token:secret@example.com:1234",
+ inputConfig: &BotConfig{},
+ expectConfig: &BotConfig{
+ Onboarding: OnboardingConfig{
+ TokenValue: "some-token",
+ JoinMethod: types.JoinMethodBoundKeypair,
+ BoundKeypair: BoundKeypairOnboardingConfig{
+ RegistrationSecret: "secret",
+ },
+ },
+ ProxyServer: "example.com:1234",
+ },
+ },
+ {
+ uri: "tbot+auth+azure://some-token:client-id@example.com:1234",
+ inputConfig: &BotConfig{},
+ expectConfig: &BotConfig{
+ Onboarding: OnboardingConfig{
+ TokenValue: "some-token",
+ JoinMethod: types.JoinMethodAzure,
+ Azure: AzureOnboardingConfig{
+ ClientID: "client-id",
+ },
+ },
+ AuthServer: "example.com:1234",
+ },
+ },
+ {
+ uri: "tbot+auth+gitlab://some-token:var-name@example.com:1234",
+ inputConfig: &BotConfig{},
+ expectConfig: &BotConfig{
+ Onboarding: OnboardingConfig{
+ TokenValue: "some-token",
+ JoinMethod: types.JoinMethodGitLab,
+ Gitlab: GitlabOnboardingConfig{
+ TokenEnvVarName: "var-name",
+ },
+ },
+ AuthServer: "example.com:1234",
+ },
+ },
+ {
+ uri: "tbot+auth+azure-devops://some-token@example.com:1234",
+ inputConfig: &BotConfig{},
+ expectConfig: &BotConfig{
+ Onboarding: OnboardingConfig{
+ TokenValue: "some-token",
+ JoinMethod: types.JoinMethodAzureDevops,
+ },
+ AuthServer: "example.com:1234",
+ },
+ },
+ {
+ uri: "tbot+auth+terraform-cloud://some-token:tag@example.com:1234",
+ inputConfig: &BotConfig{},
+ expectConfig: &BotConfig{
+ Onboarding: OnboardingConfig{
+ TokenValue: "some-token",
+ JoinMethod: types.JoinMethodTerraformCloud,
+ Terraform: TerraformOnboardingConfig{
+ AudienceTag: "tag",
+ },
+ },
+ AuthServer: "example.com:1234",
+ },
+ },
+ {
+ uri: "tbot+proxy+token://asdf@example.com:1234",
+ inputConfig: &BotConfig{
+ AuthServer: "example.com",
+ },
+ expectError: func(tt require.TestingT, err error, i ...any) {
+ require.ErrorContains(tt, err, "URI conflicts with configured field: auth_server")
+ },
+ },
+ {
+ uri: "tbot+auth+token://asdf@example.com:1234",
+ inputConfig: &BotConfig{
+ ProxyServer: "example.com",
+ },
+ expectError: func(tt require.TestingT, err error, i ...any) {
+ require.ErrorContains(tt, err, "URI conflicts with configured field: proxy_server")
+ },
+ },
+ {
+ uri: "tbot+auth+bound-keypair://asdf:secret@example.com:1234",
+ inputConfig: &BotConfig{
+ ProxyServer: "example.com",
+ Onboarding: OnboardingConfig{
+ TokenValue: "token",
+ JoinMethod: types.JoinMethodBoundKeypair,
+ BoundKeypair: BoundKeypairOnboardingConfig{
+ RegistrationSecret: "secret2",
+ },
+ },
+ },
+ expectError: func(tt require.TestingT, err error, i ...any) {
+ require.ErrorContains(tt, err, "field: onboarding.token")
+ require.ErrorContains(tt, err, "field: onboarding.bound_keypair.initial_join_secret")
+ require.ErrorContains(tt, err, "field: proxy_server")
+
+ // Note: join method is already bound_keypair so no error will
+ // be raised for that field.
+ require.NotContains(tt, err.Error(), "field: join_method")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.uri, func(t *testing.T) {
+ parsed, err := ParseJoinURI(tt.uri)
+ require.NoError(t, err)
+
+ err = parsed.ApplyToConfig(tt.inputConfig)
+ if tt.expectError != nil {
+ tt.expectError(t, err)
+ } else {
+ require.NoError(t, err)
+ require.Equal(t, tt.expectConfig, tt.inputConfig)
+ }
+ })
+ }
+}
diff --git a/lib/tbot/tbot.go b/lib/tbot/tbot.go
index 92e5dc67d454d..84aeb14595276 100644
--- a/lib/tbot/tbot.go
+++ b/lib/tbot/tbot.go
@@ -782,6 +782,17 @@ func (b *Bot) preRunChecks(ctx context.Context) (_ func() error, err error) {
ctx, span := tracer.Start(ctx, "Bot/preRunChecks")
defer func() { apitracing.EndSpan(span, err) }()
+ if b.cfg.JoinURI != "" {
+ parsed, err := config.ParseJoinURI(b.cfg.JoinURI)
+ if err != nil {
+ return nil, trace.Wrap(err, "parsing joining URI")
+ }
+
+ if err := parsed.ApplyToConfig(b.cfg); err != nil {
+ return nil, trace.Wrap(err, "applying joining URI to bot config")
+ }
+ }
+
_, addrKind := b.cfg.Address()
switch addrKind {
case config.AddressKindUnspecified:
diff --git a/lib/tbot/tbot_test.go b/lib/tbot/tbot_test.go
index cd4bead6cb962..85c31da144e2f 100644
--- a/lib/tbot/tbot_test.go
+++ b/lib/tbot/tbot_test.go
@@ -1237,3 +1237,62 @@ func TestBotSSHMultiplexer(t *testing.T) {
})
}
}
+
+// TestBotJoiningURI ensures configured joining URIs work in place of
+// traditional YAML onboarding config.
+func TestBotJoiningURI(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ log := utils.NewSlogLoggerForTests()
+
+ process := testenv.MakeTestServer(
+ t,
+ defaultTestServerOpts(t, log),
+ testenv.WithProxyKube(t),
+ )
+ rootClient := testenv.MakeDefaultAuthClient(t, process)
+
+ role, err := types.NewRole("role", types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ AppLabels: types.Labels{
+ "*": apiutils.Strings{"*"},
+ },
+ },
+ })
+ require.NoError(t, err)
+ _, err = rootClient.UpsertRole(ctx, role)
+ require.NoError(t, err)
+
+ botParams, _ := makeBot(t, rootClient, "test", "role")
+ cfg := &config.BotConfig{
+ JoinURI: fmt.Sprintf(
+ "tbot+proxy+%s://%s@%s",
+ botParams.JoinMethod,
+ botParams.TokenValue,
+ process.Config.Proxy.WebAddr.String(),
+ ),
+ Storage: &config.StorageConfig{
+ Destination: &config.DestinationMemory{},
+ },
+ Services: config.ServiceConfigs{
+ &config.IdentityOutput{
+ Destination: &config.DestinationMemory{},
+ },
+ },
+ Oneshot: true,
+ Insecure: true,
+ }
+ require.NoError(t, cfg.CheckAndSetDefaults())
+
+ bot := New(cfg, log)
+ require.NoError(t, bot.Run(ctx))
+
+ // Perform some cursory checks on the identity to make sure a cert bundle
+ // was actually produced.
+ id := bot.BotIdentity()
+ tlsIdent, err := tlsca.FromSubject(
+ id.X509Cert.Subject, id.X509Cert.NotAfter,
+ )
+ require.NoError(t, err)
+ require.Equal(t, "test", tlsIdent.BotName)
+}
diff --git a/tool/tbot/main.go b/tool/tbot/main.go
index cadd10b88092e..7878110280af2 100644
--- a/tool/tbot/main.go
+++ b/tool/tbot/main.go
@@ -355,8 +355,13 @@ func onConfigure(
out = f
}
- // Ensure they have provided a join method to use in the configuration.
- if cfg.Onboarding.JoinMethod == types.JoinMethodUnspecified {
+ // 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 {
+ return trace.Wrap(err, "invalid joining URI")
+ }
+ } else if cfg.Onboarding.JoinMethod == types.JoinMethodUnspecified {
return trace.BadParameter("join method must be provided")
}