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") }