Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion lib/tbot/cli/start_shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -140,13 +140,18 @@ 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
}

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)
Expand Down
5 changes: 5 additions & 0 deletions lib/tbot/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
211 changes: 211 additions & 0 deletions lib/tbot/config/uri.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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
}
Loading
Loading