MWI: Default to creating bound keypair tokens for bots in tctl (#63422)

* MWI: Default to creating bound keypair tokens for bots in `tctl`

This adjusts the default behavior in `tctl` to create bound keypair
tokens when creating new bot tokens in `tctl bots add` and
`tctl bots instances add`.

This adds a new "V2" message template intended for use with joining
URIs and bound keypair tokens, and defaults to that. The existing
template is still available, with minor additive changes, using the
`--legacy` flag. Additional flags to manage bound keypair token
parameters (recovery mode, etc) were also added and are specific to
this join method.

Additionally, joining URI parsing utilities were moved into their
own package to be importable from `tctl` without importing all of
`lib/tbot`.

* Fix broken TestAddAndListBotInstancesJSON

A slight control flow change introduced a dependency on a working
proxy, which isn't enabled by tctl's test harness. This enables the
proxy for this test, with a new test flag to support this.

* Fix imports

* Fix regression in TestTokens

The new helper unconditionally overrode cfg.Proxy.Enabled, which
broke other tests that overrode the parameter in other ways. It
now only toggles it on explicitly.

* Add test coverage for new bot templates

* Fix lint

* Deflake AddBot* tests with ugly static plumbing

There's a lot of random parameters in the rendered token templates
which caused failures in the golden-style tests. This plumbs through
a few static parameters along with adding a template parameter mutator
to override some parameters to ensure test consistency.

* Dedupe bot token flags

Moves bot token flag initialization into a shared
`initSharedBotTokenFlags()` helper.

* Add removal TODO for v20

* Add 'configure' instructions to the bot template
This commit is contained in:
Tim Buckley
2026-02-09 23:32:04 +00:00
committed by GitHub
parent 5305e8279b
commit bec78b14c8
12 changed files with 961 additions and 247 deletions
+10
View File
@@ -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 {
+210
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
+96
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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))
})
}
}
+12 -113
View File
@@ -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
}
+4 -71
View File
@@ -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 {
+3 -2
View File
@@ -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")
}
}
+2 -1
View File
@@ -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"
@@ -369,7 +370,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 {
+369 -57
View File
@@ -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,37 +1027,21 @@ 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 {
tokenTTL := time.Duration(0)
if exp := token.Expiry(); !exp.IsZero() {
tokenTTL = time.Until(exp)
}
// 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
// breaking customer scripts.
response := botJSONResponse{
UserName: bot.Status.UserName,
RoleName: bot.Status.RoleName,
TokenID: token.GetName(),
TokenTTL: tokenTTL,
}
out, err := json.MarshalIndent(response, "", " ")
if err != nil {
return trace.Wrap(err, "failed to marshal CreateBot response")
}
fmt.Fprintln(wr, string(out))
return nil
}
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()
@@ -902,20 +1054,125 @@ func outputToken(ctx context.Context, wr io.Writer, format string, client botsCo
}
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
// breaking customer scripts.
response := botJSONResponse{
UserName: bot.Status.UserName,
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(c.stdout, string(out))
return nil
}
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)
}
joinMethod := token.GetJoinMethod()
if joinMethod == types.JoinMethodUnspecified {
joinMethod = types.JoinMethodToken
joinMethod = types.JoinMethodBoundKeypair
}
paramTable := asciitable.MakeHeadlessTable(2)
paramTable.AddRow([]string{"Proxy:", addr})
paramTable.AddRow([]string{"Token:", token.GetName()})
paramTable.AddRow([]string{"Join Method:", string(joinMethod)})
if secret != "" {
paramTable.AddRow([]string{"Registration Secret:", secret})
}
templateData := map[string]any{
"token": token.GetName(),
"addr": addr,
"join_method": joinMethod,
"join_uri": uri.String(),
"param_table": indentString(paramTable.AsBuffer().String(), " "),
}
if !token.Expiry().IsZero() {
templateData["minutes"] = int(time.Until(token.Expiry()).Minutes())
} else if deadline := getBoundKeypairRegistrationDeadline(token); deadline != nil {
templateData["minutes"] = int(time.Until(*deadline).Minutes())
}
return startMessageTemplate.Execute(wr, templateData)
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
}
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")
}
+177 -3
View File
@@ -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,22 +450,44 @@ func TestAddAndListBotInstancesJSON(t *testing.T) {
response := botJSONResponse{}
require.NoError(t, json.Unmarshal([]byte(buf.String()), &response))
_, err = client.GetToken(ctx, response.TokenID)
token, err := client.GetToken(ctx, response.TokenID)
require.NoError(t, err)
// Make sure these are being created with the intended defaults
require.Equal(t, types.JoinMethodBoundKeypair, token.GetJoinMethod())
uri, err := joinuri.Parse(response.JoinURI)
require.NoError(t, err)
require.Equal(t, types.JoinMethodBoundKeypair, uri.JoinMethod)
require.True(t, token.Expiry().IsZero(), "bound keypair token must not expire")
// Run the command again to ensure multiple distinct tokens can be created.
buf.Reset()
require.NoError(t, cmd.AddBotInstance(ctx, client))
response2 := botJSONResponse{}
require.NoError(t, json.Unmarshal([]byte(buf.String()), &response2))
require.NotEqual(t, response.TokenID, response2.TokenID)
_, err = client.GetToken(ctx, response2.TokenID)
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) {
+10
View File
@@ -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 {
+33
View File
@@ -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/
+35
View File
@@ -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 as root:
> tbot init \
--destination-dir=./tbot-user \
--bot-user=tbot \
--reader-user=alice
... where "tbot" is the username of the bot's UNIX user, and "alice" is the
UNIX user that will be making use of the certificates.
Then, run this as the bot user to begin continuously fetching
certificates:
> tbot start \
--destination-dir=./tbot-user \
--token=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/