diff --git a/lib/tbot/cli/cli.go b/lib/tbot/cli/cli.go
new file mode 100644
index 0000000000000..ff820fc0d0f89
--- /dev/null
+++ b/lib/tbot/cli/cli.go
@@ -0,0 +1,231 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "log/slog"
+ "strings"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/lib/tbot/config"
+ logutils "github.com/gravitational/teleport/lib/utils/log"
+)
+
+const (
+ // AuthServerEnvVar is the environment variable that overrides the
+ // configured auth server address.
+ AuthServerEnvVar = "TELEPORT_AUTH_SERVER"
+ // TokenEnvVar is the environment variable that overrides the configured
+ // bot token name.
+ TokenEnvVar = "TELEPORT_BOT_TOKEN"
+ // ProxyServerEnvVar is the environment variable that overrides the
+ // configured proxy server address.
+ ProxyServerEnvVar = "TELEPORT_PROXY"
+)
+
+var log = logutils.NewPackageLogger(teleport.ComponentKey, teleport.ComponentTBot)
+
+// CommandRunner defines a contract for `TryRun` that allows commands to
+// either execute (possibly returning an error), or pass execution to the next
+// command candidate.
+type CommandRunner interface {
+ TryRun(cmd string) (match bool, err error)
+}
+
+// MutatorAction is an action that is called by a config mutator-style command.
+type MutatorAction func(mutator ConfigMutator) error
+
+// genericMutatorHandler supplies a generic `TryRun` that works for all commands
+// that - broadly - load config, mutate that config, and run an action. It's
+// meant to be embedded within a command struct to provide the `TryRun`
+// implementation.
+type genericMutatorHandler struct {
+ cmd *kingpin.CmdClause
+ mutator ConfigMutator
+ action MutatorAction
+}
+
+// newGenericMutatorHandler creates a new generic genericMutatorHandler that
+// provides a generic `TryRun` implementation.
+func newGenericMutatorHandler(cmd *kingpin.CmdClause, mutator ConfigMutator, action MutatorAction) *genericMutatorHandler {
+ return &genericMutatorHandler{
+ cmd: cmd,
+ mutator: mutator,
+ action: action,
+ }
+}
+
+func (g *genericMutatorHandler) TryRun(cmd string) (match bool, err error) {
+ switch cmd {
+ case g.cmd.FullCommand():
+ err = g.action(g.mutator)
+ default:
+ return false, nil
+ }
+
+ return true, trace.Wrap(err)
+}
+
+// ConfigMutator is an interface that can apply changes to a BotConfig.
+type ConfigMutator interface {
+ ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error
+}
+
+// genericExecutorHandler is a helper that can be embedded to provide a simpler
+// TryRun implementation that just runs a function. These functions can be
+// passed in while building the CLI to more easily glue behaviors together, or
+// specified directly.
+type genericExecutorHandler[T any] struct {
+ cmd *kingpin.CmdClause
+ args *T
+
+ // actions is a list of functions to run when `TryRun` matches the `cmd`.
+ // Generally at most one action should be exposed to the top level glue in
+ // main, but commands might want to inject some handler logic for e.g.
+ // flag migrations.
+ actions []func(*T) error
+}
+
+// newGenericExecutorHandler creates a genericExecutorHandler with the given
+// command and action to execute when that command is matched.
+func newGenericExecutorHandler[T any](cmd *kingpin.CmdClause, args *T, actions ...func(*T) error) *genericExecutorHandler[T] {
+ return &genericExecutorHandler[T]{
+ cmd: cmd,
+ args: args,
+ actions: actions,
+ }
+}
+
+func (e *genericExecutorHandler[T]) TryRun(cmd string) (match bool, err error) {
+ switch cmd {
+ case e.cmd.FullCommand():
+ for _, action := range e.actions {
+ err = action(e.args)
+ if err != nil {
+ break
+ }
+ }
+ default:
+ return false, nil
+ }
+
+ return true, trace.Wrap(err)
+}
+
+func applyMutators(l *slog.Logger, cfg *config.BotConfig, mutators ...ConfigMutator) error {
+ for _, mutator := range mutators {
+ if mutator == nil {
+ continue
+ }
+
+ if err := mutator.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+
+ return nil
+}
+
+// LoadConfigWithMutators builds a config from an optional config file and a CLI
+// mutator. If an empty path is provided, an empty base config is used. The CLI
+// mutator may override or append to the loaded configuration, if any. The
+// GlobalArgs will be applied as a mutator, and `CheckAndSetDefaults()` will be
+// called on the end result.
+func LoadConfigWithMutators(globals *GlobalArgs, mutators ...ConfigMutator) (*config.BotConfig, error) {
+ var cfg *config.BotConfig
+ var err error
+
+ if globals.staticConfigYAML != "" {
+ cfg, err = config.ReadConfig(strings.NewReader(globals.staticConfigYAML), false)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ } else if globals.ConfigPath != "" {
+ cfg, err = config.ReadConfigFromFile(globals.ConfigPath, false)
+
+ if err != nil {
+ return nil, trace.Wrap(err, "loading bot config from path %s", globals.ConfigPath)
+ }
+ } else {
+ cfg = &config.BotConfig{}
+ }
+
+ mutatorsWithGlobals := append([]ConfigMutator{globals}, mutators...)
+
+ l := log.With("config_path", globals.ConfigPath)
+ if err := applyMutators(l, cfg, mutatorsWithGlobals...); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ if err := cfg.CheckAndSetDefaults(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return cfg, nil
+}
+
+// BaseConfigWithMutators returns a base bot config with the given CLI mutators
+// applied. `CheckAndSetDefaults()` will be called on the result. This is useful
+// for explicitly _not_ loading a config file, like in `tbot configure ...`
+func BaseConfigWithMutators(mutators ...ConfigMutator) (*config.BotConfig, error) {
+ cfg := &config.BotConfig{}
+ if err := applyMutators(log, cfg, mutators...); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ if err := cfg.CheckAndSetDefaults(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return cfg, nil
+}
+
+// RemainingArgsList is a custom kingpin parser that consumes all remaining
+// arguments.
+type RemainingArgsList []string
+
+func (r *RemainingArgsList) Set(value string) error {
+ *r = append(*r, value)
+ return nil
+}
+
+func (r *RemainingArgsList) String() string {
+ return strings.Join([]string(*r), " ")
+}
+
+func (r *RemainingArgsList) IsCumulative() bool {
+ return true
+}
+
+// RemainingArgs returns a list of remaining arguments for the given command.
+func RemainingArgs(s kingpin.Settings) (target *[]string) {
+ target = new([]string)
+ s.SetValue((*RemainingArgsList)(target))
+ return
+}
+
+// KingpinClause allows commands and flags to mount to either the root app
+// (kingpin.Application) or a subcommand (kingpin.CmdClause)
+type KingpinClause interface {
+ Command(name string, help string) *kingpin.CmdClause
+ Flag(name string, help string) *kingpin.FlagClause
+}
diff --git a/lib/tbot/cli/cli_test.go b/lib/tbot/cli/cli_test.go
new file mode 100644
index 0000000000000..64ce04a6705fd
--- /dev/null
+++ b/lib/tbot/cli/cli_test.go
@@ -0,0 +1,169 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "log/slog"
+ "testing"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/tbot/config"
+ "github.com/gravitational/teleport/lib/utils"
+)
+
+func buildMinimalKingpinApp(subcommandName string) (app *kingpin.Application, subcommand *kingpin.CmdClause) {
+ app = utils.InitCLIParser("tbot", "test").Interspersed(false)
+ subcommand = app.Command(subcommandName, "subcommand")
+
+ return
+}
+
+func TestConfigCLIOnlySample(t *testing.T) {
+ // Test the sample config generated by `tctl bots add ...`
+ legacy := &LegacyCommand{
+ LegacyDestinationDirArgs: &LegacyDestinationDirArgs{
+ DestinationDir: "/tmp/foo",
+ },
+ AuthProxyArgs: NewStaticAuthServer("auth.example.com"),
+ Token: "foo",
+ CAPins: []string{"abc123"},
+ DiagAddr: "127.0.0.1:1337",
+ JoinMethod: string(types.JoinMethodToken),
+ }
+
+ cfg, err := LoadConfigWithMutators(&GlobalArgs{
+ Debug: true,
+ }, legacy)
+ require.NoError(t, err)
+
+ require.Equal(t, legacy.AuthServer, cfg.AuthServer)
+
+ require.NotNil(t, cfg.Onboarding)
+
+ token, err := cfg.Onboarding.Token()
+ require.NoError(t, err)
+ require.Equal(t, legacy.Token, token)
+ require.Equal(t, legacy.CAPins, cfg.Onboarding.CAPins)
+
+ // Storage is still default
+ storageImpl, ok := cfg.Storage.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, config.GetDefaultStoragePath(), storageImpl.Path)
+
+ // A single default Destination should exist
+ require.Len(t, cfg.Services, 1)
+ output := cfg.Services[0]
+ identOutput, ok := output.(*config.IdentityOutput)
+ require.True(t, ok)
+
+ destImpl := identOutput.GetDestination()
+ require.NoError(t, err)
+ destImplReal, ok := destImpl.(*config.DestinationDirectory)
+ require.True(t, ok)
+
+ require.Equal(t, legacy.DestinationDir, destImplReal.Path)
+ require.True(t, cfg.Debug)
+ require.Equal(t, legacy.DiagAddr, cfg.DiagAddr)
+}
+
+type basicCommand interface {
+ TryRun(cmd string) (match bool, err error)
+}
+
+type testCommandCase[O any] struct {
+ name string
+ args []string
+ assert func(t *testing.T, output O)
+}
+
+func testCommand[T basicCommand](
+ t *testing.T,
+ newCommand func(parentCommand KingpinClause, action func(T) error) T,
+ testCases []testCommandCase[T],
+) {
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ app, _ := buildMinimalKingpinApp("sub")
+ actionCalled := false
+ var actionCalledOutput T
+ cmd := newCommand(app, func(output T) error {
+ actionCalled = true
+ actionCalledOutput = output
+ return nil
+ })
+
+ command, err := app.Parse(tt.args)
+ require.NoError(t, err)
+
+ match, err := cmd.TryRun(command)
+ require.NoError(t, err)
+ require.True(t, match)
+
+ require.True(t, actionCalled)
+
+ tt.assert(t, actionCalledOutput)
+ })
+ }
+}
+
+type startConfigureCommand interface {
+ ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error
+ basicCommand
+}
+
+type startConfigureTestCase struct {
+ name string
+ args []string
+ assertConfig func(t *testing.T, cfg *config.BotConfig)
+}
+
+func testStartConfigureCommand[T startConfigureCommand](
+ t *testing.T,
+ newCommand func(parentCmd *kingpin.CmdClause, action MutatorAction) T,
+ testCases []startConfigureTestCase,
+) {
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ app, subcommand := buildMinimalKingpinApp("start")
+ actionCalled := false
+ cmd := newCommand(subcommand, func(mut ConfigMutator) error {
+ actionCalled = true
+ return nil
+ })
+
+ command, err := app.Parse(tt.args)
+ require.NoError(t, err)
+
+ match, err := cmd.TryRun(command)
+ require.NoError(t, err)
+ require.True(t, match)
+
+ require.True(t, actionCalled)
+
+ // Convert these args to a BotConfig and check it.
+ cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd)
+ require.NoError(t, err)
+
+ tt.assertConfig(t, cfg)
+ })
+ }
+}
diff --git a/lib/tbot/cli/db.go b/lib/tbot/cli/db.go
new file mode 100644
index 0000000000000..f6ea819b65952
--- /dev/null
+++ b/lib/tbot/cli/db.go
@@ -0,0 +1,68 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import "context"
+
+// DBCommand contains fields for `tbot db`
+type DBCommand struct {
+ *genericExecutorHandler[DBCommand]
+
+ DestinationDir string
+ Cluster string
+ ProxyServer string
+
+ // LegacyProxy is the legacy --proxy flag.
+ // TODO(timothyb89): DELETE IN 17.0.0
+ // TODO(timothyb89): Or maybe remove in this PR.
+ LegacyProxyFlag string
+
+ RemainingArgs *[]string
+}
+
+// NewDBCommand initializes flags for `tbot db`
+func NewDBCommand(app KingpinClause, action func(*DBCommand) error) *DBCommand {
+ cmd := app.Command("db", "Execute database commands through tsh.")
+
+ c := &DBCommand{}
+ c.genericExecutorHandler = newGenericExecutorHandler(cmd, c, func(c *DBCommand) error {
+ // Prepend an action to handle --proxy deprecation.
+ if c.LegacyProxyFlag != "" {
+ c.ProxyServer = c.LegacyProxyFlag
+ log.WarnContext(context.TODO(), "The --proxy flag is deprecated and will be removed in v17.0.0. Use --proxy-server instead")
+ }
+
+ return nil
+ }, action)
+
+ // We're migrating from --proxy to --proxy-server so this flag is hidden
+ // but still supported.
+ // TODO(strideynet): DELETE IN 17.0.0
+ cmd.Flag("proxy", "The Teleport proxy server to use, in host:port form.").Hidden().Envar(ProxyServerEnvVar).StringVar(&c.LegacyProxyFlag)
+
+ cmd.Flag("proxy-server", "The Teleport proxy server to use, in host:port form.").StringVar(&c.ProxyServer)
+ cmd.Flag("destination-dir", "The destination directory with which to authenticate tsh").StringVar(&c.DestinationDir)
+ cmd.Flag("cluster", "The cluster name. Extracted from the certificate if unset.").StringVar(&c.Cluster)
+ c.RemainingArgs = RemainingArgs(cmd.Arg(
+ "args",
+ "Arguments to `tsh db ...`; prefix with `-- ` to ensure flags are passed correctly.",
+ ))
+
+ return c
+}
diff --git a/lib/tbot/cli/db_test.go b/lib/tbot/cli/db_test.go
new file mode 100644
index 0000000000000..641acc87fe422
--- /dev/null
+++ b/lib/tbot/cli/db_test.go
@@ -0,0 +1,48 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestDBCommand(t *testing.T) {
+ testCommand(t, NewDBCommand, []testCommandCase[*DBCommand]{
+ {
+ name: "success",
+ args: []string{
+ "db",
+ "--proxy-server=example.com:22",
+ "--destination-dir=/tmp",
+ "--cluster=example.com",
+ "bar",
+ "buzz",
+ "boo",
+ },
+ assert: func(t *testing.T, got *DBCommand) {
+ require.Equal(t, "example.com:22", got.ProxyServer)
+ require.Equal(t, "/tmp", got.DestinationDir)
+ require.Equal(t, "example.com", got.Cluster)
+ require.Equal(t, []string{"bar", "buzz", "boo"}, *got.RemainingArgs)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/globals.go b/lib/tbot/cli/globals.go
new file mode 100644
index 0000000000000..dcd762920daa0
--- /dev/null
+++ b/lib/tbot/cli/globals.go
@@ -0,0 +1,110 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "log/slog"
+
+ "github.com/alecthomas/kingpin/v2"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+ "github.com/gravitational/teleport/lib/utils"
+)
+
+// GlobalArgs is a set of arguments additionally passed to all command handlers.
+// Some subset of these may be applied to configuration, and a ConfigMutator is
+// implemented to handle them; other fields may be referenced ad hoc.
+type GlobalArgs struct {
+ // FIPS instructs `tbot` to run in a mode designed to comply with FIPS
+ // regulations. This means the bot should:
+ // - Refuse to run if not compiled with boringcrypto
+ // - Use FIPS relevant endpoints for cloud providers (e.g AWS)
+ // - Restrict TLS / SSH cipher suites and TLS version
+ // - RSA2048 or ECDSA with NIST-P256 curve should be used for private key generation
+ FIPS bool
+
+ // ConfigPath is a path to a YAML configuration file to load, if any.
+ ConfigPath string
+
+ // Debug enables debug-level logging, when set
+ Debug bool
+
+ // LogFormat configures the output format of the logger
+ LogFormat string
+
+ // Trace indicates whether tracing should be enabled.
+ Trace bool
+
+ // TraceExporter is a manually provided URI to send traces to instead of
+ // forwarding them to the Auth service.
+ TraceExporter string
+
+ // Insecure instructs `tbot` to trust the Auth Server without verifying the CA.
+ Insecure bool
+
+ // staticConfigYAML allows tests to specify a configuration file statically
+ staticConfigYAML string
+}
+
+// NewGlobalArgs appends global flags to the application and returns a struct
+// that will be populated at parse time.
+func NewGlobalArgs(app *kingpin.Application) *GlobalArgs {
+ g := &GlobalArgs{}
+
+ app.Flag("debug", "Verbose logging to stdout.").Short('d').BoolVar(&g.Debug)
+ app.Flag("config", "Path to a configuration file.").Short('c').StringVar(&g.ConfigPath)
+ app.Flag("fips", "Runs tbot in FIPS compliance mode. This requires the FIPS binary is in use.").BoolVar(&g.FIPS)
+ app.Flag("trace", "Capture and export distributed traces.").Hidden().BoolVar(&g.Trace)
+ app.Flag("trace-exporter", "An OTLP exporter URL to send spans to.").Hidden().StringVar(&g.TraceExporter)
+ app.Flag("insecure", "Insecure configures the bot to trust the certificates from the Auth Server or Proxy on first connect without verification. Do not use in production.").BoolVar(&g.Insecure)
+ app.Flag("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`.").
+ Default(utils.LogFormatText).
+ EnumVar(&g.LogFormat, utils.LogFormatJSON, utils.LogFormatText)
+
+ return g
+}
+
+// NewGlobalArgsWithStaticConfig creates a new GlobalArgs instance with a static
+// YAML config. This can be used in tests to preload a config file without
+// writing to the filesystem. Note that this only works for codepaths that
+// make use of `LoadConfigWithMutators`.
+func NewGlobalArgsWithStaticConfig(staticYAML string) *GlobalArgs {
+ return &GlobalArgs{
+ staticConfigYAML: staticYAML,
+ }
+}
+
+func (g *GlobalArgs) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ // Note: g.ConfigPath is not checked here; the config must have already been
+ // loaded.
+
+ if g.FIPS {
+ cfg.FIPS = g.FIPS
+ }
+
+ if g.Debug {
+ cfg.Debug = g.Debug
+ }
+
+ if g.Insecure {
+ cfg.Insecure = true
+ }
+
+ return nil
+}
diff --git a/lib/tbot/cli/globals_test.go b/lib/tbot/cli/globals_test.go
new file mode 100644
index 0000000000000..06fdc5256a82d
--- /dev/null
+++ b/lib/tbot/cli/globals_test.go
@@ -0,0 +1,65 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestGlobals tests that GlobalArgs initialize and parse properly, and mutate
+// BotConfig as expected.
+func TestGlobalArgs(t *testing.T) {
+ app, _ := buildMinimalKingpinApp("start")
+ globals := NewGlobalArgs(app)
+
+ // Note: various flags here are already tested as part of sharedStartArgs.
+ _, err := app.Parse([]string{
+ "start",
+ "--debug",
+ "--config=foo.yaml",
+ "--fips",
+ "--trace",
+ "--trace-exporter=foo",
+ "--insecure",
+ "--log-format=json",
+ })
+ require.NoError(t, err)
+
+ require.True(t, globals.Debug)
+ require.True(t, globals.FIPS)
+ require.True(t, globals.Insecure)
+ require.True(t, globals.Trace)
+ require.Equal(t, "foo", globals.TraceExporter)
+ require.Equal(t, "foo.yaml", globals.ConfigPath)
+
+ // Clear the config path, otherwise LoadConfigWithMutators will try to load
+ // it.
+ globals.ConfigPath = ""
+
+ // Convert these args to a BotConfig and check it. Globals don't set many
+ // config flags, so not much to check here.
+ cfg, err := LoadConfigWithMutators(globals)
+ require.NoError(t, err)
+
+ require.True(t, cfg.Debug)
+ require.True(t, cfg.FIPS)
+ require.True(t, cfg.Insecure)
+}
diff --git a/lib/tbot/cli/init.go b/lib/tbot/cli/init.go
new file mode 100644
index 0000000000000..6183fd55639fd
--- /dev/null
+++ b/lib/tbot/cli/init.go
@@ -0,0 +1,75 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "log/slog"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// InitCommand implements a command for `tbot init`
+type InitCommand struct {
+ *AuthProxyArgs
+ *LegacyDestinationDirArgs
+ *genericExecutorHandler[InitCommand]
+
+ Owner string
+ BotUser string
+ ReaderUser string
+ InitDir string
+ Clean bool
+}
+
+// NewInitCommand constructs an InitCommand at the top level of the given
+// application. It will execute `action` when selected by the user.
+func NewInitCommand(app KingpinClause, action func(*InitCommand) error) *InitCommand {
+ cmd := app.Command("init", "Initialize a certificate destination directory for writes from a separate bot user.")
+
+ c := &InitCommand{}
+ c.AuthProxyArgs = newAuthProxyArgs(cmd)
+ c.LegacyDestinationDirArgs = newLegacyDestinationDirArgs(cmd)
+ c.genericExecutorHandler = newGenericExecutorHandler(cmd, c, action)
+
+ cmd.Flag("owner", "Defines Linux \"user:group\" owner of \"--destination-dir\". Defaults to the Linux user running tbot if unspecified.").StringVar(&c.Owner)
+ cmd.Flag("bot-user", "Enables POSIX ACLs and defines Linux user that can read/write short-lived certificates to \"--destination-dir\".").StringVar(&c.BotUser)
+ cmd.Flag("reader-user", "Enables POSIX ACLs and defines Linux user that will read short-lived certificates from \"--destination-dir\".").StringVar(&c.ReaderUser)
+ cmd.Flag("init-dir", "If using a config file and multiple destinations are configured, controls which destination dir to configure.").StringVar(&c.InitDir)
+ cmd.Flag("clean", "If set, remove unexpected files and directories from the destination.").BoolVar(&c.Clean)
+
+ return c
+}
+
+func (c *InitCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ if c.AuthProxyArgs != nil {
+ if err := c.AuthProxyArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+
+ if c.LegacyDestinationDirArgs != nil {
+ if err := c.LegacyDestinationDirArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+
+ return nil
+}
diff --git a/lib/tbot/cli/init_test.go b/lib/tbot/cli/init_test.go
new file mode 100644
index 0000000000000..af37c957bed1d
--- /dev/null
+++ b/lib/tbot/cli/init_test.go
@@ -0,0 +1,48 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestInitCommand(t *testing.T) {
+ testCommand(t, NewInitCommand, []testCommandCase[*InitCommand]{
+ {
+ name: "success",
+ args: []string{
+ "init",
+ "--owner=jim:wheels",
+ "--bot-user=jeffrey",
+ "--reader-user=bob",
+ "--init-dir=/tmp",
+ "--clean",
+ },
+ assert: func(t *testing.T, got *InitCommand) {
+ require.Equal(t, "jim:wheels", got.Owner)
+ require.Equal(t, "jeffrey", got.BotUser)
+ require.Equal(t, "bob", got.ReaderUser)
+ require.Equal(t, "/tmp", got.InitDir)
+ require.True(t, got.Clean)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/kube_credentials.go b/lib/tbot/cli/kube_credentials.go
new file mode 100644
index 0000000000000..1e83dda1b841a
--- /dev/null
+++ b/lib/tbot/cli/kube_credentials.go
@@ -0,0 +1,38 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+type KubeCredentialsCommand struct {
+ *genericExecutorHandler[KubeCredentialsCommand]
+
+ DestinationDir string
+}
+
+// NewKubeCredentialsCommand initializes a kubernetes `credentials` command
+// and returns a struct that will contain the parse result.
+func NewKubeCredentialsCommand(parentCmd KingpinClause, action func(*KubeCredentialsCommand) error) *KubeCredentialsCommand {
+ cmd := parentCmd.Command("credentials", "Get credentials for kubectl access").Hidden()
+
+ c := &KubeCredentialsCommand{}
+ c.genericExecutorHandler = newGenericExecutorHandler(cmd, c, action)
+
+ cmd.Flag("destination-dir", "The destination directory with which to generate Kubernetes credentials").Required().StringVar(&c.DestinationDir)
+
+ return c
+}
diff --git a/lib/tbot/cli/kube_credentials_test.go b/lib/tbot/cli/kube_credentials_test.go
new file mode 100644
index 0000000000000..ed14feeb432ee
--- /dev/null
+++ b/lib/tbot/cli/kube_credentials_test.go
@@ -0,0 +1,40 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestKubeCredentialsCommand(t *testing.T) {
+ testCommand(t, NewKubeCredentialsCommand, []testCommandCase[*KubeCredentialsCommand]{
+ {
+ name: "success",
+ args: []string{
+ "credentials",
+ "--destination-dir=/tmp",
+ },
+ assert: func(t *testing.T, got *KubeCredentialsCommand) {
+ require.Equal(t, "/tmp", got.DestinationDir)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/migrate.go b/lib/tbot/cli/migrate.go
new file mode 100644
index 0000000000000..da44590208349
--- /dev/null
+++ b/lib/tbot/cli/migrate.go
@@ -0,0 +1,40 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+// MigrateCommand contains fields parsed for `tbot migrate`
+type MigrateCommand struct {
+ *genericExecutorHandler[MigrateCommand]
+
+ // ConfigureOutput is the path to write the file. If empty, writes to
+ // stdout.
+ ConfigureOutput string
+}
+
+// NewMigrateCommand initializes the `tbot migrate` command and its flags.
+func NewMigrateCommand(app KingpinClause, action func(*MigrateCommand) error) *MigrateCommand {
+ cmd := app.Command("migrate", "Migrates a config file from an older version to the newest version. Outputs to stdout by default.")
+
+ c := &MigrateCommand{}
+ c.genericExecutorHandler = newGenericExecutorHandler(cmd, c, action)
+
+ cmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&c.ConfigureOutput)
+
+ return c
+}
diff --git a/lib/tbot/cli/migrate_test.go b/lib/tbot/cli/migrate_test.go
new file mode 100644
index 0000000000000..ea3d59546718f
--- /dev/null
+++ b/lib/tbot/cli/migrate_test.go
@@ -0,0 +1,40 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestMigrateCommand(t *testing.T) {
+ testCommand(t, NewMigrateCommand, []testCommandCase[*MigrateCommand]{
+ {
+ name: "success",
+ args: []string{
+ "migrate",
+ "--output=/tmp",
+ },
+ assert: func(t *testing.T, got *MigrateCommand) {
+ require.Equal(t, "/tmp", got.ConfigureOutput)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/proxy.go b/lib/tbot/cli/proxy.go
new file mode 100644
index 0000000000000..556f793016bee
--- /dev/null
+++ b/lib/tbot/cli/proxy.go
@@ -0,0 +1,70 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "context"
+)
+
+// ProxyCommand supports `tbot proxy`
+type ProxyCommand struct {
+ *genericExecutorHandler[ProxyCommand]
+
+ DestinationDir string
+ Cluster string
+ ProxyServer string
+
+ // LegacyProxy is the legacy --proxy flag.
+ // TODO(timothyb89): DELETE IN 17.0.0
+ // TODO(timothyb89): Or maybe remove in this PR.
+ LegacyProxyFlag string
+
+ ProxyRemaining *[]string
+}
+
+// NewProxyCommand initializes the subcommand for `tbot proxy`
+func NewProxyCommand(app KingpinClause, action func(*ProxyCommand) error) *ProxyCommand {
+ cmd := app.Command("proxy", "Start a local TLS proxy via tsh to connect to Teleport in single-port mode.")
+
+ c := &ProxyCommand{}
+ c.genericExecutorHandler = newGenericExecutorHandler(cmd, c, func(c *ProxyCommand) error {
+ // Prepend an action to handle --proxy deprecation.
+ if c.LegacyProxyFlag != "" {
+ c.ProxyServer = c.LegacyProxyFlag
+ log.WarnContext(context.TODO(), "The --proxy flag is deprecated and will be removed in v17.0.0. Use --proxy-server instead")
+ }
+
+ return nil
+ }, action)
+
+ // We're migrating from --proxy to --proxy-server so this flag is hidden
+ // but still supported.
+ // TODO(strideynet): DELETE IN 17.0.0
+ cmd.Flag("proxy", "The Teleport proxy server to use, in host:port form.").Hidden().StringVar(&c.LegacyProxyFlag)
+
+ cmd.Flag("proxy-server", "The Teleport proxy server to use, in host:port form.").Envar(ProxyServerEnvVar).StringVar(&c.ProxyServer)
+ cmd.Flag("destination-dir", "The destination directory with which to authenticate tsh").StringVar(&c.DestinationDir)
+ cmd.Flag("cluster", "The cluster name. Extracted from the certificate if unset.").StringVar(&c.Cluster)
+ c.ProxyRemaining = RemainingArgs(cmd.Arg(
+ "args",
+ "Arguments to `tsh proxy ...`; prefix with `-- ` to ensure flags are passed correctly.",
+ ))
+
+ return c
+}
diff --git a/lib/tbot/cli/proxy_test.go b/lib/tbot/cli/proxy_test.go
new file mode 100644
index 0000000000000..bcd65b99f113a
--- /dev/null
+++ b/lib/tbot/cli/proxy_test.go
@@ -0,0 +1,48 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestProxyCommand(t *testing.T) {
+ testCommand(t, NewProxyCommand, []testCommandCase[*ProxyCommand]{
+ {
+ name: "success",
+ args: []string{
+ "proxy",
+ "--proxy-server=example.com:22",
+ "--destination-dir=/tmp",
+ "--cluster=example.com",
+ "bar",
+ "buzz",
+ "boo",
+ },
+ assert: func(t *testing.T, got *ProxyCommand) {
+ require.Equal(t, "example.com:22", got.ProxyServer)
+ require.Equal(t, "/tmp", got.DestinationDir)
+ require.Equal(t, "example.com", got.Cluster)
+ require.Equal(t, []string{"bar", "buzz", "boo"}, *got.ProxyRemaining)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/ssh_multiplexer_proxy.go b/lib/tbot/cli/ssh_multiplexer_proxy.go
new file mode 100644
index 0000000000000..d3757b59437e8
--- /dev/null
+++ b/lib/tbot/cli/ssh_multiplexer_proxy.go
@@ -0,0 +1,43 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+// SSHMultiplexerProxyCommand includes fields for `tbot ssh-multiplexer-proxy-command`
+type SSHMultiplexerProxyCommand struct {
+ *genericExecutorHandler[SSHMultiplexerProxyCommand]
+
+ Socket string
+ Data string
+}
+
+// NewSSHMultiplexerProxyCommand initializes and parses args for `tbot ssh-multiplexer-proxy-command`
+func NewSSHMultiplexerProxyCommand(app KingpinClause, action func(*SSHMultiplexerProxyCommand) error) *SSHMultiplexerProxyCommand {
+ cmd := app.Command(
+ "ssh-multiplexer-proxy-command",
+ "An OpenSSH compatible ProxyCommand which connects to a long-lived tbot running the ssh-multiplexer service",
+ ).Hidden()
+
+ c := &SSHMultiplexerProxyCommand{}
+ c.genericExecutorHandler = newGenericExecutorHandler(cmd, c, action)
+
+ cmd.Arg("path", "Path to the listener socket.").Required().StringVar(&c.Socket)
+ cmd.Arg("data", "Connection target.").Required().StringVar(&c.Data)
+
+ return c
+}
diff --git a/lib/tbot/cli/ssh_multiplexer_proxy_test.go b/lib/tbot/cli/ssh_multiplexer_proxy_test.go
new file mode 100644
index 0000000000000..8179bc7f346d1
--- /dev/null
+++ b/lib/tbot/cli/ssh_multiplexer_proxy_test.go
@@ -0,0 +1,42 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSSHMultiplexerProxyCommand(t *testing.T) {
+ testCommand(t, NewSSHMultiplexerProxyCommand, []testCommandCase[*SSHMultiplexerProxyCommand]{
+ {
+ name: "success",
+ args: []string{
+ "ssh-multiplexer-proxy-command",
+ "/tmp/sock",
+ "example.com:22",
+ },
+ assert: func(t *testing.T, got *SSHMultiplexerProxyCommand) {
+ require.Equal(t, "/tmp/sock", got.Socket)
+ require.Equal(t, "example.com:22", got.Data)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/ssh_proxy.go b/lib/tbot/cli/ssh_proxy.go
new file mode 100644
index 0000000000000..2db0bc2ee32e9
--- /dev/null
+++ b/lib/tbot/cli/ssh_proxy.go
@@ -0,0 +1,81 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+// SSHProxyCommand includes fields for `tbot ssh-proxy-command`
+type SSHProxyCommand struct {
+ *genericExecutorHandler[SSHProxyCommand]
+
+ // DestinationDir stores the generated end-user certificates.
+ DestinationDir string
+
+ // Cluster is the name of the Teleport cluster on which resources should
+ // be accessed.
+ Cluster string
+
+ // ProxyServer is the teleport proxy address. Unlike `AuthServer` this must
+ // explicitly point to a Teleport proxy.
+ // Example: "example.teleport.sh:443"
+ ProxyServer string
+
+ // User is the os login to use for ssh connections.
+ User string
+
+ // Host is the target ssh machine to connect to.
+ Host string
+
+ // Post is the post of the ssh machine to connect on.
+ Port string
+
+ // EnableResumption turns on automatic session resumption to prevent connections from
+ // being dropped if Proxy connectivity is lost.
+ EnableResumption bool
+
+ // TLSRoutingEnabled indicates whether the cluster has TLS routing enabled.
+ TLSRoutingEnabled bool
+
+ // ConnectionUpgradeRequired indicates that an ALPN connection upgrade is required
+ // for connections to the cluster.
+ ConnectionUpgradeRequired bool
+
+ // TSHConfigPath is the path to a tsh config file.
+ TSHConfigPath string
+}
+
+// NewSSHProxyCommand initializes the `tbot ssh-proxy-command` subcommand and
+// its fields.
+func NewSSHProxyCommand(app KingpinClause, action func(*SSHProxyCommand) error) *SSHProxyCommand {
+ cmd := app.Command("ssh-proxy-command", "An OpenSSH/PuTTY proxy command").Hidden()
+
+ c := &SSHProxyCommand{}
+ c.genericExecutorHandler = newGenericExecutorHandler(cmd, c, action)
+
+ cmd.Flag("destination-dir", "The destination directory with which to authenticate tsh").StringVar(&c.DestinationDir)
+ cmd.Flag("cluster", "The cluster name. Extracted from the certificate if unset.").StringVar(&c.Cluster)
+ cmd.Flag("user", "The remote user name for the connection").Required().StringVar(&c.User)
+ cmd.Flag("host", "The remote host to connect to").Required().StringVar(&c.Host)
+ cmd.Flag("port", "The remote port to connect on.").StringVar(&c.Port)
+ cmd.Flag("proxy-server", "The Teleport proxy server to use, in host:port form.").Required().StringVar(&c.ProxyServer)
+ cmd.Flag("tls-routing", "Whether the Teleport cluster has tls routing enabled.").Required().BoolVar(&c.TLSRoutingEnabled)
+ cmd.Flag("connection-upgrade", "Whether the Teleport cluster requires an ALPN connection upgrade.").Required().BoolVar(&c.ConnectionUpgradeRequired)
+ cmd.Flag("proxy-templates", "The path to a file containing proxy templates to be evaluated.").StringVar(&c.TSHConfigPath)
+ cmd.Flag("resume", "Enable SSH connection resumption").BoolVar(&c.EnableResumption)
+
+ return c
+}
diff --git a/lib/tbot/cli/ssh_proxy_test.go b/lib/tbot/cli/ssh_proxy_test.go
new file mode 100644
index 0000000000000..9818c3b52f6a2
--- /dev/null
+++ b/lib/tbot/cli/ssh_proxy_test.go
@@ -0,0 +1,56 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSSHProxyCommand(t *testing.T) {
+ testCommand(t, NewSSHProxyCommand, []testCommandCase[*SSHProxyCommand]{
+ {
+ name: "success",
+ args: []string{
+ "ssh-proxy-command",
+ "--destination-dir=/bar",
+ "--cluster=foo",
+ "--user=noah",
+ "--host=example.com",
+ "--proxy-server=example.com:443",
+ "--tls-routing",
+ "--connection-upgrade",
+ "--proxy-templates=/tmp/tsh.yaml",
+ "--resume",
+ },
+ assert: func(t *testing.T, got *SSHProxyCommand) {
+ require.Equal(t, "/bar", got.DestinationDir)
+ require.Equal(t, "foo", got.Cluster)
+ require.Equal(t, "noah", got.User)
+ require.Equal(t, "example.com", got.Host)
+ require.Equal(t, "example.com:443", got.ProxyServer)
+ require.True(t, got.TLSRoutingEnabled)
+ require.True(t, got.ConnectionUpgradeRequired)
+ require.Equal(t, "/tmp/tsh.yaml", got.TSHConfigPath)
+ require.True(t, got.EnableResumption)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/start_application.go b/lib/tbot/cli/start_application.go
new file mode 100644
index 0000000000000..2d3665cdd25b9
--- /dev/null
+++ b/lib/tbot/cli/start_application.go
@@ -0,0 +1,76 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "log/slog"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// ApplicationCommand implements `tbot start application` and
+// `tbot configure application`.
+type ApplicationCommand struct {
+ *sharedStartArgs
+ *genericMutatorHandler
+
+ Destination string
+ AppName string
+ SpecificTLSExtensions bool
+}
+
+// NewApplicationCommand initializes a command and flag for application outputs
+// and returns a struct that will contain the parse result.
+func NewApplicationCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *ApplicationCommand {
+ cmd := parentCmd.Command("application", "Starts with an application output").Alias("app")
+
+ c := &ApplicationCommand{}
+ c.sharedStartArgs = newSharedStartArgs(cmd)
+ c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action)
+
+ cmd.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&c.Destination)
+ cmd.Flag("app", "The name of the app in Teleport").Required().StringVar(&c.AppName)
+ cmd.Flag("specific-tls-extensions", "If set, include additional `tls.crt`, `tls.key`, and `tls.cas` for apps that require these file extensions").BoolVar(&c.SpecificTLSExtensions)
+
+ // Note: CLI will not support roles; all will be requested.
+
+ return c
+}
+
+func (c *ApplicationCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+
+ dest, err := config.DestinationFromURI(c.Destination)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ cfg.Services = append(cfg.Services, &config.ApplicationOutput{
+ Destination: dest,
+ AppName: c.AppName,
+ SpecificTLSExtensions: c.SpecificTLSExtensions,
+ })
+
+ return nil
+}
diff --git a/lib/tbot/cli/start_application_test.go b/lib/tbot/cli/start_application_test.go
new file mode 100644
index 0000000000000..1d0b562621af1
--- /dev/null
+++ b/lib/tbot/cli/start_application_test.go
@@ -0,0 +1,60 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// TestApplicationCommand tests that the ApplicationCommand properly parses its
+// arguments and applies as expected onto a BotConfig.
+func TestApplicationCommand(t *testing.T) {
+ testStartConfigureCommand(t, NewApplicationCommand, []startConfigureTestCase{
+ {
+ name: "success",
+ args: []string{
+ "start",
+ "application",
+ "--destination=/bar",
+ "--token=foo",
+ "--join-method=github",
+ "--proxy-server=example.com:443",
+ "--app=foo",
+ "--specific-tls-extensions",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ // It must configure a app output with a directory destination.
+ svc := cfg.Services[0]
+ appSvc, ok := svc.(*config.ApplicationOutput)
+ require.True(t, ok)
+
+ require.Equal(t, "foo", appSvc.AppName)
+ require.True(t, appSvc.SpecificTLSExtensions)
+
+ dir, ok := appSvc.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, "/bar", dir.Path)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/start_application_tunnel.go b/lib/tbot/cli/start_application_tunnel.go
new file mode 100644
index 0000000000000..2d6ea0541b734
--- /dev/null
+++ b/lib/tbot/cli/start_application_tunnel.go
@@ -0,0 +1,68 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "log/slog"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// ApplicationTunnelCommand implements `tbot start application-tunnel` and
+// `tbot configure application-tunnel`.
+type ApplicationTunnelCommand struct {
+ *sharedStartArgs
+ *genericMutatorHandler
+
+ Listen string
+ AppName string
+}
+
+// NewApplicationTunnelCommand initializes flags for an app tunnel command and
+// returns a struct to contain the parse result.
+func NewApplicationTunnelCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *ApplicationTunnelCommand {
+ cmd := parentCmd.Command("application-tunnel", "Starts an application tunnel").Alias("app-tunnel")
+
+ c := &ApplicationTunnelCommand{}
+ c.sharedStartArgs = newSharedStartArgs(cmd)
+ c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action)
+
+ cmd.Flag("listen", "A socket URI, such as tcp://0.0.0.0:8080").Required().StringVar(&c.Listen)
+ cmd.Flag("app", "The name of the app in Teleport").Required().StringVar(&c.AppName)
+
+ // Note: CLI will not support roles; all will be requested.
+
+ return c
+}
+
+func (c *ApplicationTunnelCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+
+ cfg.Services = append(cfg.Services, &config.ApplicationTunnelService{
+ Listen: c.Listen,
+ AppName: c.AppName,
+ })
+
+ return nil
+}
diff --git a/lib/tbot/cli/start_application_tunnel_test.go b/lib/tbot/cli/start_application_tunnel_test.go
new file mode 100644
index 0000000000000..049c18d9864a6
--- /dev/null
+++ b/lib/tbot/cli/start_application_tunnel_test.go
@@ -0,0 +1,57 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// TestApplicationTunnelCommand tests that the ApplicationTunnelCommand
+// properly parses its arguments and applies as expected onto a BotConfig.
+func TestApplicationTunnelCommand(t *testing.T) {
+ testStartConfigureCommand(t, NewApplicationTunnelCommand, []startConfigureTestCase{
+ {
+ name: "success",
+ args: []string{
+ "start",
+ "application-tunnel",
+ "--token=foo",
+ "--join-method=github",
+ "--proxy-server=example.com:443",
+ "--app=foo",
+ "--listen=tcp://0.0.0.0:8000",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ require.Len(t, cfg.Services, 1)
+
+ // It must configure an app tunnel service
+ svc := cfg.Services[0]
+ appSvc, ok := svc.(*config.ApplicationTunnelService)
+ require.True(t, ok)
+
+ require.Equal(t, "foo", appSvc.AppName)
+ require.Equal(t, "tcp://0.0.0.0:8000", appSvc.Listen)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/start_database.go b/lib/tbot/cli/start_database.go
new file mode 100644
index 0000000000000..01bc0fd8fb600
--- /dev/null
+++ b/lib/tbot/cli/start_database.go
@@ -0,0 +1,80 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "log/slog"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// DatabaseCommand implements `tbot start database` and
+// `tbot configure database`.
+type DatabaseCommand struct {
+ *sharedStartArgs
+ *genericMutatorHandler
+
+ Destination string
+ Format string
+ Service string
+ Username string
+ Database string
+}
+
+// NewDatabaseCommand initializes a command and flags for database outputs and
+// returns a struct that will contain the parse result.
+func NewDatabaseCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *DatabaseCommand {
+ cmd := parentCmd.Command("database", "Starts with a database output").Alias("db")
+
+ c := &DatabaseCommand{}
+ c.sharedStartArgs = newSharedStartArgs(cmd)
+ c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action)
+
+ cmd.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&c.Destination)
+ cmd.Flag("format", "The database output format if necessary").Default("").EnumVar(&c.Format, config.SupportedDatabaseFormatStrings()...)
+ cmd.Flag("service", "The database service name").Required().StringVar(&c.Service)
+ cmd.Flag("username", "The database user name").Required().StringVar(&c.Username)
+ cmd.Flag("database", "The name of the database available in the requested database service").Required().StringVar(&c.Database)
+
+ return c
+}
+
+func (c *DatabaseCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+
+ dest, err := config.DestinationFromURI(c.Destination)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ cfg.Services = append(cfg.Services, &config.DatabaseOutput{
+ Destination: dest,
+ Format: config.DatabaseFormat(c.Format),
+ Username: c.Username,
+ Database: c.Database,
+ Service: c.Service,
+ })
+
+ return nil
+}
diff --git a/lib/tbot/cli/start_database_test.go b/lib/tbot/cli/start_database_test.go
new file mode 100644
index 0000000000000..89008d623ace8
--- /dev/null
+++ b/lib/tbot/cli/start_database_test.go
@@ -0,0 +1,66 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// TestDatabaseCommand tests that the DatabaseCommand properly parses its
+// arguments and applies as expected onto a BotConfig.
+func TestDatabaseCommand(t *testing.T) {
+ testStartConfigureCommand(t, NewDatabaseCommand, []startConfigureTestCase{
+ {
+ name: "success",
+ args: []string{
+ "start",
+ "database",
+ "--destination=/bar",
+ "--token=foo",
+ "--join-method=github",
+ "--proxy-server=example.com:443",
+ "--service=foo",
+ "--username=bar",
+ "--database=baz",
+ "--format=tls",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ require.Len(t, cfg.Services, 1)
+
+ // It must configure a database output with a directory destination.
+ svc := cfg.Services[0]
+ db, ok := svc.(*config.DatabaseOutput)
+ require.True(t, ok)
+
+ require.Equal(t, "foo", db.Service)
+ require.Equal(t, "bar", db.Username)
+ require.Equal(t, "baz", db.Database)
+ require.Equal(t, config.TLSDatabaseFormat, db.Format)
+
+ dir, ok := db.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, "/bar", dir.Path)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/start_database_tunnel.go b/lib/tbot/cli/start_database_tunnel.go
new file mode 100644
index 0000000000000..be863f6a933f4
--- /dev/null
+++ b/lib/tbot/cli/start_database_tunnel.go
@@ -0,0 +1,73 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "log/slog"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// DatabaseTunnelCommand implements `tbot start database-tunnel` and
+// `tbot configure database-tunnel`.
+type DatabaseTunnelCommand struct {
+ *sharedStartArgs
+ *genericMutatorHandler
+
+ Listen string
+ Service string
+ Username string
+ Database string
+}
+
+// NewDatabaseTunnelCommand creates a command supporting `tbot start database-tunnel`
+func NewDatabaseTunnelCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *DatabaseTunnelCommand {
+ cmd := parentCmd.Command("database-tunnel", "Start a database tunnel listener").Alias("db-tunnel")
+
+ c := &DatabaseTunnelCommand{}
+ c.sharedStartArgs = newSharedStartArgs(cmd)
+ c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action)
+
+ cmd.Flag("listen", "A socket URI to listen on, such as tcp://0.0.0.0:3306").Required().StringVar(&c.Listen)
+ cmd.Flag("service", "The database service name").Required().StringVar(&c.Service)
+ cmd.Flag("username", "The database user name").Required().StringVar(&c.Username)
+ cmd.Flag("database", "The name of the database available in the requested database service").Required().StringVar(&c.Database)
+
+ // Note: excluding roles from the CLI; will default to all available.
+
+ return c
+}
+
+func (c *DatabaseTunnelCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+
+ cfg.Services = append(cfg.Services, &config.DatabaseTunnelService{
+ Listen: c.Listen,
+ Username: c.Username,
+ Database: c.Database,
+ Service: c.Service,
+ })
+
+ return nil
+}
diff --git a/lib/tbot/cli/start_database_tunnel_test.go b/lib/tbot/cli/start_database_tunnel_test.go
new file mode 100644
index 0000000000000..7e15dc5fe2782
--- /dev/null
+++ b/lib/tbot/cli/start_database_tunnel_test.go
@@ -0,0 +1,61 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// TestDatabaseTunnelCommand tests that the DatabaseTunnelCommand
+// properly parses its arguments and applies as expected onto a BotConfig.
+func TestDatabaseTunnelCommand(t *testing.T) {
+ testStartConfigureCommand(t, NewDatabaseTunnelCommand, []startConfigureTestCase{
+ {
+ name: "success",
+ args: []string{
+ "start",
+ "database-tunnel",
+ "--token=foo",
+ "--join-method=github",
+ "--proxy-server=example.com:443",
+ "--listen=tcp://0.0.0.0:8000",
+ "--service=foo",
+ "--username=bar",
+ "--database=baz",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ require.Len(t, cfg.Services, 1)
+
+ // It must configure a db tunnel service
+ svc := cfg.Services[0]
+ db, ok := svc.(*config.DatabaseTunnelService)
+ require.True(t, ok)
+
+ require.Equal(t, "tcp://0.0.0.0:8000", db.Listen)
+ require.Equal(t, "foo", db.Service)
+ require.Equal(t, "bar", db.Username)
+ require.Equal(t, "baz", db.Database)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/start_identity.go b/lib/tbot/cli/start_identity.go
new file mode 100644
index 0000000000000..0898c998f1fee
--- /dev/null
+++ b/lib/tbot/cli/start_identity.go
@@ -0,0 +1,73 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "log/slog"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// IdentityCommand implements `tbot start identity` and
+// `tbot configure identity`.
+type IdentityCommand struct {
+ *sharedStartArgs
+ *genericMutatorHandler
+
+ Destination string
+ Cluster string
+}
+
+// NewIdentityCommand initializes the command and flags for identity outputs
+// and returns a struct that will contain the parse result.
+func NewIdentityCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *IdentityCommand {
+ cmd := parentCmd.Command("identity", "Start with an identity output for SSH and Teleport API access").Alias("ssh").Alias("id")
+
+ c := &IdentityCommand{}
+ c.sharedStartArgs = newSharedStartArgs(cmd)
+ c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action)
+
+ cmd.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&c.Destination)
+ cmd.Flag("cluster", "The name of a specific cluster for which to issue an identity if using a leaf cluster").StringVar(&c.Cluster)
+
+ // Note: roles and ssh_config mode are excluded for now.
+
+ return c
+}
+
+func (c *IdentityCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+
+ dest, err := config.DestinationFromURI(c.Destination)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ cfg.Services = append(cfg.Services, &config.IdentityOutput{
+ Destination: dest,
+ Cluster: c.Cluster,
+ })
+
+ return nil
+}
diff --git a/lib/tbot/cli/start_identity_test.go b/lib/tbot/cli/start_identity_test.go
new file mode 100644
index 0000000000000..606b07babc202
--- /dev/null
+++ b/lib/tbot/cli/start_identity_test.go
@@ -0,0 +1,81 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// TestIdentityCommand tests that the IdentityCommand properly parses its arguments
+// and applies as expected onto a BotConfig.
+func TestIdentityCommand(t *testing.T) {
+ testStartConfigureCommand(t, NewIdentityCommand, []startConfigureTestCase{
+ {
+ name: "success",
+ args: []string{
+ "start",
+ "identity",
+ "--token=foo",
+ "--ca-pin=bar",
+ "--certificate-ttl=10m",
+ "--renewal-interval=5m",
+ "--join-method=github",
+ "--oneshot",
+ "--diag-addr=0.0.0.0:8080",
+ "--storage=/foo",
+ "--destination=file:///bar",
+ "--proxy-server=example.com:443",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ token, err := cfg.Onboarding.Token()
+ require.NoError(t, err)
+ require.Equal(t, "foo", token)
+
+ require.ElementsMatch(t, cfg.Onboarding.CAPins, []string{"bar"})
+ require.Equal(t, time.Minute*10, cfg.CertificateTTL)
+ require.Equal(t, time.Minute*5, cfg.RenewalInterval)
+ require.Equal(t, types.JoinMethodGitHub, cfg.Onboarding.JoinMethod)
+ require.True(t, cfg.Oneshot)
+ require.Equal(t, "0.0.0.0:8080", cfg.DiagAddr)
+ require.Equal(t, "example.com:443", cfg.ProxyServer)
+
+ dir, ok := cfg.Storage.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, "/foo", dir.Path)
+
+ require.Len(t, cfg.Services, 1)
+
+ // It must configure an identity output with a directory destination.
+ svc := cfg.Services[0]
+ ident, ok := svc.(*config.IdentityOutput)
+ require.True(t, ok)
+
+ dir, ok = ident.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, "/bar", dir.Path)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/start_kubernetes.go b/lib/tbot/cli/start_kubernetes.go
new file mode 100644
index 0000000000000..028a09fb649a1
--- /dev/null
+++ b/lib/tbot/cli/start_kubernetes.go
@@ -0,0 +1,76 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "log/slog"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// KubernetesCommand implements `tbot start kubernetes` and
+// `tbot configure kubernetes`.
+type KubernetesCommand struct {
+ *sharedStartArgs
+ *genericMutatorHandler
+
+ Destination string
+ KubernetesCluster string
+ DisableExecPlugin bool
+}
+
+// NewKubernetesCommand initializes the command and flags for kubernetes outputs
+// and returns a struct to contain the parse result.
+func NewKubernetesCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *KubernetesCommand {
+ cmd := parentCmd.Command("kubernetes", "Starts with a kubernetes output").Alias("k8s")
+
+ c := &KubernetesCommand{}
+ c.sharedStartArgs = newSharedStartArgs(cmd)
+ c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action)
+
+ cmd.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&c.Destination)
+ cmd.Flag("kubernetes-cluster", "The name of the Kubernetes cluster in Teleport for which to fetch credentials").Required().StringVar(&c.KubernetesCluster)
+ cmd.Flag("disable-exec-plugin", "If set, disables the exec plugin. This allows credentials to be used without the `tbot` binary.").BoolVar(&c.DisableExecPlugin)
+
+ // Note: excluding roles; the bot will fetch all available in CLI mode.
+
+ return c
+}
+
+func (c *KubernetesCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+
+ dest, err := config.DestinationFromURI(c.Destination)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ cfg.Services = append(cfg.Services, &config.KubernetesOutput{
+ Destination: dest,
+ KubernetesCluster: c.KubernetesCluster,
+ DisableExecPlugin: c.DisableExecPlugin,
+ })
+
+ return nil
+}
diff --git a/lib/tbot/cli/start_kubernetes_test.go b/lib/tbot/cli/start_kubernetes_test.go
new file mode 100644
index 0000000000000..183ae805b3b0d
--- /dev/null
+++ b/lib/tbot/cli/start_kubernetes_test.go
@@ -0,0 +1,62 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// TestKubernetesCommand tests that the KubernetesCommand properly parses its
+// arguments and applies as expected onto a BotConfig.
+func TestKubernetesCommand(t *testing.T) {
+ testStartConfigureCommand(t, NewKubernetesCommand, []startConfigureTestCase{
+ {
+ name: "success",
+ args: []string{
+ "start",
+ "kubernetes",
+ "--destination=/bar",
+ "--token=foo",
+ "--join-method=github",
+ "--proxy-server=example.com:443",
+ "--kubernetes-cluster=demo",
+ "--disable-exec-plugin",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ require.Len(t, cfg.Services, 1)
+
+ // It must configure a kubernetes output with a directory destination.
+ svc := cfg.Services[0]
+ k8s, ok := svc.(*config.KubernetesOutput)
+ require.True(t, ok)
+
+ require.Equal(t, "demo", k8s.KubernetesCluster)
+ require.True(t, k8s.DisableExecPlugin)
+
+ dir, ok := k8s.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, "/bar", dir.Path)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/start_legacy.go b/lib/tbot/cli/start_legacy.go
new file mode 100644
index 0000000000000..95f412bdf82f2
--- /dev/null
+++ b/lib/tbot/cli/start_legacy.go
@@ -0,0 +1,272 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "reflect"
+ "strings"
+ "time"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// LegacyDestinationDirArgs is an embeddable struct that provides legacy-style
+// --destination-dir handling, largely for reuse by `start legacy` and other
+// older subcommands like `init`.
+type LegacyDestinationDirArgs struct {
+ // DestinationDir stores the generated end-user certificates.
+ DestinationDir string
+}
+
+// newLegacyDestinationDirArgs initializes the legacy --destination-dir flag on
+// the given command, and returns a struct that will contain the parse result.
+func newLegacyDestinationDirArgs(cmd *kingpin.CmdClause) *LegacyDestinationDirArgs {
+ args := &LegacyDestinationDirArgs{}
+
+ cmd.Flag("destination-dir", "Directory to write short-lived machine certificates.").StringVar(&args.DestinationDir)
+
+ return args
+}
+
+func (a *LegacyDestinationDirArgs) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ if a.DestinationDir != "" {
+ // WARNING:
+ // See: https://github.com/gravitational/teleport/issues/27206 for
+ // potential gotchas that currently exist when dealing with this
+ // override behavior.
+
+ // CLI only supports a single filesystem Destination with SSH client config
+ // and all roles.
+ if len(cfg.Services) > 0 {
+ log.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding output services",
+ "flag", "destination-dir",
+ "cli_value", a.DestinationDir,
+ )
+ }
+
+ // When using the CLI --destination-dir we configure an Identity type
+ // output for that directory.
+ cfg.Services = []config.ServiceConfig{
+ &config.IdentityOutput{
+ Destination: &config.DestinationDirectory{
+ Path: a.DestinationDir,
+ },
+ },
+ }
+ }
+
+ return nil
+}
+
+// LegacyCommand starts with legacy behavior. This handles flags somewhat
+// differently and maintains support for certain deprecated flags, so does not
+// use `sharedStartArgs`.
+type LegacyCommand struct {
+ *AuthProxyArgs
+ *LegacyDestinationDirArgs
+
+ // cmd is the concrete command for this instance
+ cmd *kingpin.CmdClause
+
+ // action is the action that will be performed if this command is selected
+ action MutatorAction
+
+ // LogFormat controls the format of logging. Can be either `json` or `text`.
+ // By default, this is `text`.
+ LogFormat string
+
+ // DataDir stores the bot's internal data.
+ DataDir string
+
+ // CAPins is a list of pinned SKPI hashes of trusted auth server CAs, used
+ // only on first connect.
+ CAPins []string
+
+ // Token is a bot join token.
+ Token string
+
+ // RenewalInterval is the interval at which certificates are renewed, as a
+ // time.ParseDuration() string. It must be less than the certificate TTL.
+ RenewalInterval time.Duration
+
+ // CertificateTTL is the requested TTL of certificates. It should be some
+ // multiple of the renewal interval to allow for failed renewals.
+ CertificateTTL time.Duration
+
+ // JoinMethod is the method the bot should use to exchange a token for the
+ // initial certificate
+ JoinMethod string
+
+ // Oneshot controls whether the bot quits after a single renewal.
+ Oneshot bool
+
+ // DiagAddr is the address the diagnostics http service should listen on.
+ // If not set, no diagnostics listener is created.
+ DiagAddr string
+}
+
+// NewLegacyCommand initializes and returns a command supporting
+// `tbot start legacy` and `tbot configure legacy`.
+func NewLegacyCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *LegacyCommand {
+ joinMethodList := fmt.Sprintf(
+ "(%s)",
+ strings.Join(config.SupportedJoinMethods, ", "),
+ )
+
+ c := &LegacyCommand{
+ action: action,
+ cmd: parentCmd.Command("legacy", "Start with either a config file or a legacy output").Default(),
+ }
+ c.AuthProxyArgs = newAuthProxyArgs(c.cmd)
+ c.LegacyDestinationDirArgs = newLegacyDestinationDirArgs(c.cmd)
+
+ c.cmd.Flag("data-dir", "Directory to store internal bot data. Access to this directory should be limited.").StringVar(&c.DataDir)
+ c.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(&c.Token)
+ c.cmd.Flag("ca-pin", "CA pin to validate the Teleport Auth Server; used on first connect.").StringsVar(&c.CAPins)
+ c.cmd.Flag("certificate-ttl", "TTL of short-lived machine certificates.").DurationVar(&c.CertificateTTL)
+ c.cmd.Flag("renewal-interval", "Interval at which short-lived certificates are renewed; must be less than the certificate TTL.").DurationVar(&c.RenewalInterval)
+ c.cmd.Flag("join-method", "Method to use to join the cluster. "+joinMethodList).EnumVar(&c.JoinMethod, config.SupportedJoinMethods...)
+ c.cmd.Flag("oneshot", "If set, quit after the first renewal.").BoolVar(&c.Oneshot)
+ c.cmd.Flag("diag-addr", "If set and the bot is in debug mode, a diagnostics service will listen on specified address.").StringVar(&c.DiagAddr)
+
+ return c
+}
+
+func (c *LegacyCommand) TryRun(cmd string) (match bool, err error) {
+ switch cmd {
+ case c.cmd.FullCommand():
+ err = c.action(c)
+ default:
+ return false, nil
+ }
+
+ return true, trace.Wrap(err)
+}
+
+func (c *LegacyCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ // Note: Debug, FIPS, and Insecure are included from globals
+
+ if c.AuthProxyArgs != nil {
+ if err := c.AuthProxyArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+
+ if c.LegacyDestinationDirArgs != nil {
+ if err := c.LegacyDestinationDirArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+
+ if c.Oneshot {
+ cfg.Oneshot = true
+ }
+
+ if c.CertificateTTL != 0 {
+ if cfg.CertificateTTL != 0 {
+ log.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding configuration",
+ "flag", "certificate-ttl",
+ "config_value", cfg.CertificateTTL,
+ "cli_value", c.CertificateTTL,
+ )
+ }
+ cfg.CertificateTTL = c.CertificateTTL
+ }
+
+ if c.RenewalInterval != 0 {
+ if cfg.RenewalInterval != 0 {
+ log.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding configuration",
+ "flag", "renewal-interval",
+ "config_value", cfg.RenewalInterval,
+ "cli_value", c.RenewalInterval,
+ )
+ }
+ cfg.RenewalInterval = c.RenewalInterval
+ }
+
+ // DataDir overrides any previously-configured storage config
+ if c.DataDir != "" {
+ if cfg.Storage != nil && cfg.Storage.Destination != nil {
+ log.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding configuration",
+ "flag", "data-dir",
+ "config_value", cfg.Storage.Destination.String(),
+ "cli_value", c.DataDir,
+ )
+ }
+
+ dest, err := config.DestinationFromURI(c.DataDir)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ cfg.Storage = &config.StorageConfig{Destination: dest}
+ }
+
+ // If any onboarding flags are set, override the whole section.
+ // (CAPath, CAPins, etc follow different codepaths so we don't want a
+ // situation where different fields become set weirdly due to struct
+ // merging)
+ if c.Token != "" || c.JoinMethod != "" || len(c.CAPins) > 0 {
+ if !reflect.DeepEqual(cfg.Onboarding, config.OnboardingConfig{}) {
+ // To be safe, warn about possible confusion.
+ log.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding join configuration",
+ "cli_token", c.Token,
+ "cli_join_method", c.JoinMethod,
+ "cli_ca_pins_count", len(c.CAPins),
+ )
+ }
+
+ cfg.Onboarding = config.OnboardingConfig{
+ CAPins: c.CAPins,
+ JoinMethod: types.JoinMethod(c.JoinMethod),
+ }
+ cfg.Onboarding.SetToken(c.Token)
+ }
+
+ if c.DiagAddr != "" {
+ if cfg.DiagAddr != "" {
+ log.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding configuration",
+ "flag", "diag-addr",
+ "config_value", cfg.DiagAddr,
+ "cli_value", c.DiagAddr,
+ )
+ }
+ cfg.DiagAddr = c.DiagAddr
+ }
+
+ return nil
+}
diff --git a/lib/tbot/cli/start_legacy_test.go b/lib/tbot/cli/start_legacy_test.go
new file mode 100644
index 0000000000000..93a40a75b3da1
--- /dev/null
+++ b/lib/tbot/cli/start_legacy_test.go
@@ -0,0 +1,80 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// TestLegacyCommand tests that the LegacyCommand properly parses its arguments
+// and applies as expected onto a BotConfig.
+func TestLegacyCommand(t *testing.T) {
+ testStartConfigureCommand(t, NewLegacyCommand, []startConfigureTestCase{
+ {
+ name: "success",
+ args: []string{
+ "start", // Note: implied legacy, it should be the default.
+ "--token=foo",
+ "--ca-pin=bar",
+ "--certificate-ttl=10m",
+ "--renewal-interval=5m",
+ "--join-method=github",
+ "--oneshot",
+ "--diag-addr=0.0.0.0:8080",
+ "--data-dir=/foo",
+ "--destination-dir=/bar",
+ "--auth-server=example.com:3024",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ token, err := cfg.Onboarding.Token()
+ require.NoError(t, err)
+ require.Equal(t, "foo", token)
+
+ require.ElementsMatch(t, cfg.Onboarding.CAPins, []string{"bar"})
+ require.Equal(t, time.Minute*10, cfg.CertificateTTL)
+ require.Equal(t, time.Minute*5, cfg.RenewalInterval)
+ require.Equal(t, types.JoinMethodGitHub, cfg.Onboarding.JoinMethod)
+ require.True(t, cfg.Oneshot)
+ require.Equal(t, "0.0.0.0:8080", cfg.DiagAddr)
+ require.Equal(t, "example.com:3024", cfg.AuthServer)
+
+ dir, ok := cfg.Storage.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, "/foo", dir.Path)
+
+ require.Len(t, cfg.Services, 1)
+
+ // It must configure an identity output with a directory destination.
+ svc := cfg.Services[0]
+ ident, ok := svc.(*config.IdentityOutput)
+ require.True(t, ok)
+
+ dir, ok = ident.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, "/bar", dir.Path)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/cli/start_shared.go b/lib/tbot/cli/start_shared.go
new file mode 100644
index 0000000000000..310405990c881
--- /dev/null
+++ b/lib/tbot/cli/start_shared.go
@@ -0,0 +1,235 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "reflect"
+ "strings"
+ "time"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// AuthProxyArgs is an embeddable struct that can add --auth-server and
+// --proxy-server to arbitrary commands for reuse.
+type AuthProxyArgs struct {
+ // AuthServer is a Teleport auth server address. It may either point
+ // directly to an auth server, or to a Teleport proxy server in which case
+ // a tunneled auth connection will be established.
+ // Prefer using Address() to pick an address.
+ AuthServer string
+
+ // ProxyServer is the teleport proxy address. Unlike `AuthServer` this must
+ // explicitly point to a Teleport proxy.
+ // Example: "example.teleport.sh:443"
+ ProxyServer string
+}
+
+// NewStaticAuthServer returns an AuthProxyArgs with the given AuthServer field
+// configured. Used in tests.
+func NewStaticAuthServer(authServer string) *AuthProxyArgs {
+ return &AuthProxyArgs{
+ AuthServer: authServer,
+ }
+}
+
+// newAuthProxyArgs initializes --auth-server and --proxy-server args on the
+// given command. This can be embedded in any parent command that needs to
+// accept an auth or proxy address. Note that `ApplyConfig` will need to be
+// called in the parent's own `ApplyConfig`.
+func newAuthProxyArgs(cmd *kingpin.CmdClause) *AuthProxyArgs {
+ args := &AuthProxyArgs{}
+
+ cmd.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(AuthServerEnvVar).StringVar(&args.AuthServer)
+ cmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(ProxyServerEnvVar).StringVar(&args.ProxyServer)
+
+ return args
+}
+
+func (a *AuthProxyArgs) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ if a.AuthServer != "" {
+ if cfg.AuthServer != "" {
+ log.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding configuration",
+ "flag", "auth-server",
+ "config_value", cfg.AuthServer,
+ "cli_value", a.AuthServer,
+ )
+ }
+ cfg.AuthServer = a.AuthServer
+ }
+
+ if a.ProxyServer != "" {
+ if cfg.ProxyServer != "" {
+ l.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding configuration",
+ "flag", "proxy-server",
+ "config_value", cfg.ProxyServer,
+ "cli_value", a.ProxyServer,
+ )
+ }
+ cfg.ProxyServer = a.ProxyServer
+ }
+
+ return nil
+}
+
+// sharedStartArgs are arguments that are shared between all modern `start` and
+// `configure` subcommands.
+type sharedStartArgs struct {
+ *AuthProxyArgs
+
+ JoinMethod string
+ Token string
+ CAPins []string
+ CertificateTTL time.Duration
+ RenewalInterval time.Duration
+ Storage string
+
+ Oneshot bool
+ DiagAddr string
+}
+
+// newSharedStartArgs initializes shared arguments on the given parent command.
+func newSharedStartArgs(cmd *kingpin.CmdClause) *sharedStartArgs {
+ args := &sharedStartArgs{}
+ args.AuthProxyArgs = newAuthProxyArgs(cmd)
+
+ joinMethodList := fmt.Sprintf(
+ "(%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)
+ cmd.Flag("renewal-interval", "Interval at which short-lived certificates are renewed; must be less than the certificate TTL.").DurationVar(&args.RenewalInterval)
+ cmd.Flag("join-method", "Method to use to join the cluster. "+joinMethodList).EnumVar(&args.JoinMethod, config.SupportedJoinMethods...)
+ cmd.Flag("oneshot", "If set, quit after the first renewal.").BoolVar(&args.Oneshot)
+ 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)
+
+ return args
+}
+
+func (s *sharedStartArgs) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ // Note: Debug, FIPS, and Insecure are included from globals.
+
+ if s.AuthProxyArgs != nil {
+ if err := s.AuthProxyArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+ }
+
+ if s.Oneshot {
+ cfg.Oneshot = true
+ }
+
+ if s.CertificateTTL != 0 {
+ if cfg.CertificateTTL != 0 {
+ l.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding configuration",
+ "flag", "certificate-ttl",
+ "config_value", cfg.CertificateTTL,
+ "cli_value", s.CertificateTTL,
+ )
+ }
+ cfg.CertificateTTL = s.CertificateTTL
+ }
+
+ if s.RenewalInterval != 0 {
+ if cfg.RenewalInterval != 0 {
+ l.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding configuration",
+ "flag", "renewal-interval",
+ "config_value", cfg.RenewalInterval,
+ "cli_value", s.RenewalInterval,
+ )
+ }
+ cfg.RenewalInterval = s.RenewalInterval
+ }
+
+ if s.DiagAddr != "" {
+ if cfg.DiagAddr != "" {
+ log.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding configuration",
+ "flag", "diag-addr",
+ "config_value", cfg.DiagAddr,
+ "cli_value", s.DiagAddr,
+ )
+ }
+ cfg.DiagAddr = s.DiagAddr
+ }
+
+ // Storage overrides any previously-configured storage config
+ if s.Storage != "" {
+ if cfg.Storage != nil && cfg.Storage.Destination != nil {
+ l.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding configuration",
+ "flag", "storage",
+ "config_value", cfg.Storage.Destination.String(),
+ "cli_value", s.Storage,
+ )
+ }
+
+ dest, err := config.DestinationFromURI(s.Storage)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ cfg.Storage = &config.StorageConfig{Destination: dest}
+ }
+
+ // If any onboarding flags are set, override the whole section.
+ // (CAPath, CAPins, etc follow different codepaths so we don't want a
+ // situation where different fields become set weirdly due to struct
+ // merging)
+ if s.Token != "" || s.JoinMethod != "" || len(s.CAPins) > 0 {
+ if !reflect.DeepEqual(cfg.Onboarding, config.OnboardingConfig{}) {
+ // To be safe, warn about possible confusion.
+ l.WarnContext(
+ context.TODO(),
+ "CLI parameters are overriding join configuration",
+ "cli_token", s.Token,
+ "cli_join_method", s.JoinMethod,
+ "cli_ca_pins_count", len(s.CAPins),
+ )
+ }
+
+ cfg.Onboarding = config.OnboardingConfig{
+ CAPins: s.CAPins,
+ JoinMethod: types.JoinMethod(s.JoinMethod),
+ }
+ cfg.Onboarding.SetToken(s.Token)
+ }
+
+ return nil
+}
diff --git a/lib/tbot/cli/start_shared_test.go b/lib/tbot/cli/start_shared_test.go
new file mode 100644
index 0000000000000..5ec8cbd2f3d0d
--- /dev/null
+++ b/lib/tbot/cli/start_shared_test.go
@@ -0,0 +1,76 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+func TestSharedStartArgs(t *testing.T) {
+ app, subcommand := buildMinimalKingpinApp("test")
+ args := newSharedStartArgs(subcommand)
+ _, err := app.Parse([]string{
+ "test",
+ "--token=foo",
+ "--ca-pin=bar",
+ "--certificate-ttl=10m",
+ "--renewal-interval=5m",
+ "--join-method=github",
+ "--oneshot",
+ "--diag-addr=0.0.0.0:8080",
+ "--storage=file:///foo/bar",
+ "--proxy-server=example.teleport.sh:443",
+ })
+ require.NoError(t, err)
+
+ require.Equal(t, "foo", args.Token)
+ require.Len(t, args.CAPins, 1)
+ require.Equal(t, "bar", args.CAPins[0])
+ require.Equal(t, time.Minute*10, args.CertificateTTL)
+ require.Equal(t, time.Minute*5, args.RenewalInterval)
+ require.True(t, args.Oneshot)
+ require.Equal(t, "0.0.0.0:8080", args.DiagAddr)
+ require.Equal(t, "file:///foo/bar", args.Storage)
+ require.Equal(t, "example.teleport.sh:443", args.ProxyServer)
+
+ // Convert these args to a BotConfig.
+ cfg, err := LoadConfigWithMutators(&GlobalArgs{}, args)
+ require.NoError(t, err)
+
+ token, err := cfg.Onboarding.Token()
+ require.NoError(t, err)
+ require.Equal(t, "foo", token)
+
+ require.ElementsMatch(t, cfg.Onboarding.CAPins, []string{"bar"})
+ require.Equal(t, time.Minute*10, cfg.CertificateTTL)
+ require.Equal(t, time.Minute*5, cfg.RenewalInterval)
+ require.Equal(t, types.JoinMethodGitHub, cfg.Onboarding.JoinMethod)
+ require.True(t, cfg.Oneshot)
+ require.Equal(t, "0.0.0.0:8080", cfg.DiagAddr)
+
+ dir, ok := cfg.Storage.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, "/foo/bar", dir.Path)
+}
diff --git a/lib/tbot/cli/start_spiffe_svid.go b/lib/tbot/cli/start_spiffe_svid.go
new file mode 100644
index 0000000000000..fd602fdb5d287
--- /dev/null
+++ b/lib/tbot/cli/start_spiffe_svid.go
@@ -0,0 +1,89 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "log/slog"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// SPIFFESVIDCommand implements `tbot start spiffe-svid` and
+// `tbot configure spiffe-svid`.
+type SPIFFESVIDCommand struct {
+ *sharedStartArgs
+ *genericMutatorHandler
+
+ Destination string
+ IncludeFederatedTrustBundles bool
+
+ SVIDPath string
+ SVIDHint string
+ DNSSANs []string
+ IPSANs []string
+}
+
+// NewSPIFFESVIDCommand initializes the command and flags for the
+// `spiffe-svid` output and returns a struct that will contain the parse
+// result.
+func NewSPIFFESVIDCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *SPIFFESVIDCommand {
+ cmd := parentCmd.Command("spiffe-svid", "Starts with a SPIFFE-compatible SVID output")
+
+ c := &SPIFFESVIDCommand{}
+ c.sharedStartArgs = newSharedStartArgs(cmd)
+ c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action)
+
+ cmd.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&c.Destination)
+ cmd.Flag("include-federated-trust-bundles", "If set, include federated trust bundles in the output").BoolVar(&c.IncludeFederatedTrustBundles)
+ cmd.Flag("svid-path", "A SPIFFE ID to request, prefixed with '/'").Required().StringVar(&c.SVIDPath)
+ cmd.Flag("svid-hint", "An optional hint for consumers of the SVID to aid in identification").StringVar(&c.SVIDHint)
+ cmd.Flag("dns-san", "A DNS name that should be included in the SVID. Repeatable.").StringsVar(&c.DNSSANs)
+ cmd.Flag("ip-san", "An IP address that should be included in the SVID. Repeatable.").StringsVar(&c.IPSANs)
+
+ return c
+}
+
+func (c *SPIFFESVIDCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error {
+ if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil {
+ return trace.Wrap(err)
+ }
+
+ dest, err := config.DestinationFromURI(c.Destination)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ cfg.Services = append(cfg.Services, &config.SPIFFESVIDOutput{
+ Destination: dest,
+ SVID: config.SVIDRequest{
+ Path: c.SVIDPath,
+ Hint: c.SVIDHint,
+ SANS: config.SVIDRequestSANs{
+ DNS: c.DNSSANs,
+ IP: c.IPSANs,
+ },
+ },
+ IncludeFederatedTrustBundles: c.IncludeFederatedTrustBundles,
+ })
+
+ return nil
+}
diff --git a/lib/tbot/cli/start_spiffe_svid_test.go b/lib/tbot/cli/start_spiffe_svid_test.go
new file mode 100644
index 0000000000000..94528318f5518
--- /dev/null
+++ b/lib/tbot/cli/start_spiffe_svid_test.go
@@ -0,0 +1,74 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 cli
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/lib/tbot/config"
+)
+
+// TestSPIFFESVIDCommand tests that the SPIFFESVIDCommand properly parses its
+// arguments and applies as expected onto a BotConfig.
+func TestSPIFFESVIDCommand(t *testing.T) {
+ testStartConfigureCommand(t, NewSPIFFESVIDCommand, []startConfigureTestCase{
+ {
+ name: "success",
+ args: []string{
+ "start",
+ "spiffe-svid",
+ "--destination=/bar",
+ "--token=foo",
+ "--join-method=github",
+ "--proxy-server=example.com:443",
+ "--include-federated-trust-bundles",
+ "--svid-path=/foo/bar",
+ "--svid-hint=hello world",
+ "--dns-san=foo.example.com",
+ "--dns-san=bar.example.com",
+ "--ip-san=192.168.1.1",
+ "--ip-san=192.168.1.2",
+ },
+ assertConfig: func(t *testing.T, cfg *config.BotConfig) {
+ require.Len(t, cfg.Services, 1)
+
+ // It must configure a SPIFFE output with a directory destination.
+ svc := cfg.Services[0]
+ spiffe, ok := svc.(*config.SPIFFESVIDOutput)
+ require.True(t, ok)
+
+ require.True(t, spiffe.IncludeFederatedTrustBundles)
+
+ svid := spiffe.SVID
+ require.Equal(t, "/foo/bar", svid.Path)
+ require.Equal(t, "hello world", svid.Hint)
+
+ sans := svid.SANS
+ require.ElementsMatch(t, sans.DNS, []string{"foo.example.com", "bar.example.com"})
+ require.ElementsMatch(t, sans.IP, []string{"192.168.1.1", "192.168.1.2"})
+
+ dir, ok := spiffe.Destination.(*config.DestinationDirectory)
+ require.True(t, ok)
+ require.Equal(t, "/bar", dir.Path)
+ },
+ },
+ })
+}
diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go
index 72e25cc513f24..2d049abcfe623 100644
--- a/lib/tbot/config/config.go
+++ b/lib/tbot/config/config.go
@@ -23,12 +23,10 @@ import (
"fmt"
"io"
"net/url"
- "reflect"
"slices"
"strings"
"time"
- "github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"go.opentelemetry.io/otel"
"gopkg.in/yaml.v3"
@@ -64,155 +62,6 @@ var SupportedJoinMethods = []string{
var log = logutils.NewPackageLogger(teleport.ComponentKey, teleport.ComponentTBot)
-// RemainingArgsList is a custom kingpin parser that consumes all remaining
-// arguments.
-type RemainingArgsList []string
-
-func (r *RemainingArgsList) Set(value string) error {
- *r = append(*r, value)
- return nil
-}
-
-func (r *RemainingArgsList) String() string {
- return strings.Join([]string(*r), " ")
-}
-
-func (r *RemainingArgsList) IsCumulative() bool {
- return true
-}
-
-// RemainingArgs returns a list of remaining arguments for the given command.
-func RemainingArgs(s kingpin.Settings) (target *[]string) {
- target = new([]string)
- s.SetValue((*RemainingArgsList)(target))
- return
-}
-
-// CLIConf is configuration from the CLI.
-type CLIConf struct {
- ConfigPath string
-
- Debug bool
-
- // LogFormat controls the format of logging. Can be either `json` or `text`.
- // By default, this is `text`.
- LogFormat string
-
- // AuthServer is a Teleport auth server address. It may either point
- // directly to an auth server, or to a Teleport proxy server in which case
- // a tunneled auth connection will be established.
- // Prefer using Address() to pick an address.
- AuthServer string
-
- // DataDir stores the bot's internal data.
- DataDir string
-
- // DestinationDir stores the generated end-user certificates.
- DestinationDir string
-
- // CAPins is a list of pinned SKPI hashes of trusted auth server CAs, used
- // only on first connect.
- CAPins []string
-
- // Token is a bot join token.
- Token string
-
- // RenewalInterval is the interval at which certificates are renewed, as a
- // time.ParseDuration() string. It must be less than the certificate TTL.
- RenewalInterval time.Duration
-
- // CertificateTTL is the requested TTL of certificates. It should be some
- // multiple of the renewal interval to allow for failed renewals.
- CertificateTTL time.Duration
-
- // JoinMethod is the method the bot should use to exchange a token for the
- // initial certificate
- JoinMethod string
-
- // Oneshot controls whether the bot quits after a single renewal.
- Oneshot bool
-
- // InitDir specifies which Destination to initialize if multiple are
- // configured.
- InitDir string
-
- // BotUser is a Unix username that should be given permission to write
- BotUser string
-
- // ReaderUser is the Unix username that will be reading the files
- ReaderUser string
-
- // Owner is the user:group that will own the Destination files. Due to SSH
- // restrictions on key permissions, it cannot be the same as the reader
- // user. If ACL support is unused or unavailable, the reader user will own
- // files directly.
- Owner string
-
- // Clean is a flag that, if set, instructs `tbot init` to remove existing
- // unexpected files.
- Clean bool
-
- // ConfigureOutput provides a path that the generated configuration file
- // should be written to
- ConfigureOutput string
-
- // ProxyServer is the teleport proxy address. Unlike `AuthServer` this must
- // explicitly point to a Teleport proxy.
- // Example: "example.teleport.sh:443"
- ProxyServer string
-
- // Cluster is the name of the Teleport cluster on which resources should
- // be accessed.
- Cluster string
-
- // RemainingArgs is the remaining string arguments for commands that
- // require them.
- RemainingArgs []string
-
- // FIPS instructs `tbot` to run in a mode designed to comply with FIPS
- // regulations. This means the bot should:
- // - Refuse to run if not compiled with boringcrypto
- // - Use FIPS relevant endpoints for cloud providers (e.g AWS)
- // - Restrict TLS / SSH cipher suites and TLS version
- // - RSA2048 or ECDSA with NIST-P256 curve should be used for private key generation
- FIPS bool
-
- // DiagAddr is the address the diagnostics http service should listen on.
- // If not set, no diagnostics listener is created.
- DiagAddr string
-
- // Insecure instructs `tbot` to trust the Auth Server without verifying the CA.
- Insecure bool
-
- // Trace indicates whether tracing should be enabled.
- Trace bool
-
- // TraceExporter is a manually provided URI to send traces to instead of
- // forwarding them to the Auth service.
- TraceExporter string
-
- // User is the os login to use for ssh connections.
- User string
- // Host is the target ssh machine to connect to.
- Host string
- // Post is the post of the ssh machine to connect on.
- Port string
-
- // EnableResumption turns on automatic session resumption to prevent connections from
- // being dropped if Proxy connectivity is lost.
- EnableResumption bool
-
- // TLSRoutingEnabled indicates whether the cluster has TLS routing enabled.
- TLSRoutingEnabled bool
-
- // ConnectionUpgradeRequired indicates that an ALPN connection upgrade is required
- // for connections to the cluster.
- ConnectionUpgradeRequired bool
-
- // TSHConfigPath is the path to a tsh config file.
- TSHConfigPath string
-}
-
// AzureOnboardingConfig holds configuration relevant to the "azure" join method.
type AzureOnboardingConfig struct {
// ClientID of the managed identity to use. Required if the VM has more
@@ -622,7 +471,9 @@ func (conf *BotConfig) GetInitables() []Initable {
return out
}
-func destinationFromURI(uriString string) (bot.Destination, error) {
+// DestinationFromURI parses a URI from the input string and returns a matching
+// bot.Destination implementation, if possible.
+func DestinationFromURI(uriString string) (bot.Destination, error) {
uri, err := url.Parse(uriString)
if err != nil {
return nil, trace.Wrap(err, "parsing --data-dir")
@@ -674,165 +525,6 @@ func destinationFromURI(uriString string) (bot.Destination, error) {
}
}
-// FromCLIConf loads bot config from CLI parameters, potentially loading and
-// merging a configuration file if specified. CheckAndSetDefaults() will
-// be called. Note that CLI flags, if specified, will override file values.
-func FromCLIConf(cf *CLIConf) (*BotConfig, error) {
- var config *BotConfig
- var err error
-
- if cf.ConfigPath != "" {
- config, err = ReadConfigFromFile(cf.ConfigPath, false)
-
- if err != nil {
- return nil, trace.Wrap(err, "loading bot config from path %s", cf.ConfigPath)
- }
- } else {
- config = &BotConfig{}
- }
-
- if cf.Debug {
- config.Debug = true
- }
-
- if cf.Oneshot {
- config.Oneshot = true
- }
-
- if cf.AuthServer != "" {
- if config.AuthServer != "" {
- log.WarnContext(
- context.TODO(),
- "CLI parameters are overriding destinations",
- "config_path", cf.ConfigPath,
- )
- }
- config.AuthServer = cf.AuthServer
- }
-
- if cf.ProxyServer != "" {
- if config.ProxyServer != "" {
- log.WarnContext(
- context.TODO(),
- "CLI parameters are overriding destinations",
- "config_path", cf.ConfigPath,
- )
- }
- config.ProxyServer = cf.ProxyServer
- }
-
- if cf.CertificateTTL != 0 {
- if config.CertificateTTL != 0 {
- log.WarnContext(
- context.TODO(),
- "CLI parameters are overriding destinations",
- "config_path", cf.ConfigPath,
- )
- }
- config.CertificateTTL = cf.CertificateTTL
- }
-
- if cf.RenewalInterval != 0 {
- if config.RenewalInterval != 0 {
- log.WarnContext(
- context.TODO(),
- "CLI parameters are overriding destinations",
- "config_path", cf.ConfigPath,
- )
- }
- config.RenewalInterval = cf.RenewalInterval
- }
-
- // DataDir overrides any previously-configured storage config
- if cf.DataDir != "" {
- if config.Storage != nil && config.Storage.Destination != nil {
- log.WarnContext(
- context.TODO(),
- "CLI parameters are overriding destinations",
- "config_path", cf.ConfigPath,
- )
- }
- dest, err := destinationFromURI(cf.DataDir)
- if err != nil {
- return nil, trace.Wrap(err)
- }
- config.Storage = &StorageConfig{Destination: dest}
- }
-
- if cf.DestinationDir != "" {
- // WARNING:
- // See: https://github.com/gravitational/teleport/issues/27206 for
- // potential gotchas that currently exist when dealing with this
- // override behavior.
-
- // CLI only supports a single filesystem Destination with SSH client config
- // and all roles.
- if len(config.Services) > 0 {
- log.WarnContext(
- context.TODO(),
- "CLI parameters are overriding destinations",
- "config_path", cf.ConfigPath,
- )
- }
-
- // When using the CLI --Destination-dir we configure an Identity type
- // output for that directory.
- config.Services = []ServiceConfig{
- &IdentityOutput{
- Destination: &DestinationDirectory{
- Path: cf.DestinationDir,
- },
- },
- }
- }
-
- // If any onboarding flags are set, override the whole section.
- // (CAPath, CAPins, etc follow different codepaths so we don't want a
- // situation where different fields become set weirdly due to struct
- // merging)
- if cf.Token != "" || cf.JoinMethod != "" || len(cf.CAPins) > 0 {
- if !reflect.DeepEqual(config.Onboarding, OnboardingConfig{}) {
- // To be safe, warn about possible confusion.
- log.WarnContext(
- context.TODO(),
- "CLI parameters are overriding destinations",
- "config_path", cf.ConfigPath,
- )
- }
-
- config.Onboarding = OnboardingConfig{
- CAPins: cf.CAPins,
- JoinMethod: types.JoinMethod(cf.JoinMethod),
- }
- config.Onboarding.SetToken(cf.Token)
- }
-
- if cf.FIPS {
- config.FIPS = cf.FIPS
- }
-
- if cf.DiagAddr != "" {
- if config.DiagAddr != "" {
- log.WarnContext(
- context.TODO(),
- "CLI parameters are overriding destinations",
- "config_path", cf.ConfigPath,
- )
- }
- config.DiagAddr = cf.DiagAddr
- }
-
- if err := config.CheckAndSetDefaults(); err != nil {
- return nil, trace.Wrap(err, "validating merged bot config")
- }
-
- if cf.Insecure {
- config.Insecure = true
- }
-
- return config, nil
-}
-
// ReadConfigFromFile reads and parses a YAML config from a file.
func ReadConfigFromFile(filePath string, manualMigration bool) (*BotConfig, error) {
f, err := utils.OpenFileAllowingUnsafeLinks(filePath)
diff --git a/lib/tbot/config/config_storage.go b/lib/tbot/config/config_storage.go
index d95950ee87d6a..11237a0492187 100644
--- a/lib/tbot/config/config_storage.go
+++ b/lib/tbot/config/config_storage.go
@@ -61,3 +61,8 @@ func (sc *StorageConfig) UnmarshalYAML(node *yaml.Node) error {
sc.Destination = dest
return nil
}
+
+// GetDefaultStoragePath returns the default internal storage path for tbot.
+func GetDefaultStoragePath() string {
+ return defaultStoragePath
+}
diff --git a/lib/tbot/config/config_test.go b/lib/tbot/config/config_test.go
index 123e1b6b6c8e3..9769b89253005 100644
--- a/lib/tbot/config/config_test.go
+++ b/lib/tbot/config/config_test.go
@@ -30,56 +30,11 @@ import (
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
- "github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/tbot/bot"
"github.com/gravitational/teleport/lib/tbot/botfs"
"github.com/gravitational/teleport/lib/utils/golden"
)
-func TestConfigCLIOnlySample(t *testing.T) {
- // Test the sample config generated by `tctl bots add ...`
- cf := CLIConf{
- DestinationDir: "/tmp/foo",
- Token: "foo",
- CAPins: []string{"abc123"},
- AuthServer: "auth.example.com",
- DiagAddr: "127.0.0.1:1337",
- Debug: true,
- JoinMethod: string(types.JoinMethodToken),
- }
- cfg, err := FromCLIConf(&cf)
- require.NoError(t, err)
-
- require.Equal(t, cf.AuthServer, cfg.AuthServer)
-
- require.NotNil(t, cfg.Onboarding)
-
- token, err := cfg.Onboarding.Token()
- require.NoError(t, err)
- require.Equal(t, cf.Token, token)
- require.Equal(t, cf.CAPins, cfg.Onboarding.CAPins)
-
- // Storage is still default
- storageImpl, ok := cfg.Storage.Destination.(*DestinationDirectory)
- require.True(t, ok)
- require.Equal(t, defaultStoragePath, storageImpl.Path)
-
- // A single default Destination should exist
- require.Len(t, cfg.Services, 1)
- output := cfg.Services[0]
- identOutput, ok := output.(*IdentityOutput)
- require.True(t, ok)
-
- destImpl := identOutput.GetDestination()
- require.NoError(t, err)
- destImplReal, ok := destImpl.(*DestinationDirectory)
- require.True(t, ok)
-
- require.Equal(t, cf.DestinationDir, destImplReal.Path)
- require.Equal(t, cf.Debug, cfg.Debug)
- require.Equal(t, cf.DiagAddr, cfg.DiagAddr)
-}
-
func TestConfigFile(t *testing.T) {
configData := fmt.Sprintf(exampleConfigFile, "foo")
cfg, err := ReadConfig(strings.NewReader(configData), false)
@@ -214,7 +169,7 @@ func TestDestinationFromURI(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
- got, err := destinationFromURI(tt.in)
+ got, err := DestinationFromURI(tt.in)
if tt.wantErr {
require.Error(t, err)
return
diff --git a/lib/tbot/config/service_database.go b/lib/tbot/config/service_database.go
index dfa7ce9e66562..764f5364c07cd 100644
--- a/lib/tbot/config/service_database.go
+++ b/lib/tbot/config/service_database.go
@@ -196,3 +196,13 @@ func (o *DatabaseOutput) UnmarshalYAML(node *yaml.Node) error {
func (o *DatabaseOutput) Type() string {
return DatabaseOutputType
}
+
+// SupportedDatabaseFormatStrings returns a constant list of all valid
+// DatabaseFormat values as strings.
+func SupportedDatabaseFormatStrings() (ret []string) {
+ for _, v := range databaseFormats {
+ ret = append(ret, string(v))
+ }
+
+ return
+}
diff --git a/tool/tbot/db.go b/tool/tbot/db.go
index 13f7e13797833..e2fe32375a31a 100644
--- a/tool/tbot/db.go
+++ b/tool/tbot/db.go
@@ -23,12 +23,18 @@ import (
"github.com/gravitational/trace"
+ "github.com/gravitational/teleport/lib/tbot/cli"
"github.com/gravitational/teleport/lib/tbot/config"
"github.com/gravitational/teleport/lib/tbot/tshwrap"
"github.com/gravitational/teleport/lib/utils"
)
-func onDBCommand(botConfig *config.BotConfig, cf *config.CLIConf) error {
+func onDBCommand(globalCfg *cli.GlobalArgs, dbCmd *cli.DBCommand) error {
+ botConfig, err := cli.LoadConfigWithMutators(globalCfg)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
wrapper, err := tshwrap.New()
if err != nil {
return trace.Wrap(err)
@@ -50,16 +56,16 @@ func onDBCommand(botConfig *config.BotConfig, cf *config.CLIConf) error {
return trace.Wrap(err)
}
- args := []string{"-i", identityPath, "db", "--proxy=" + cf.ProxyServer}
- if cf.Cluster != "" {
+ args := []string{"-i", identityPath, "db", "--proxy=" + dbCmd.ProxyServer}
+ if dbCmd.Cluster != "" {
// If we caught --cluster in our args, pass it through.
- args = append(args, "--cluster="+cf.Cluster)
- } else if !utils.HasPrefixAny("--cluster", cf.RemainingArgs) {
+ args = append(args, "--cluster="+dbCmd.Cluster)
+ } else if !utils.HasPrefixAny("--cluster", *dbCmd.RemainingArgs) {
// If no `--cluster` was provided after a `--`, pass along the cluster
// name in the identity.
args = append(args, "--cluster="+identity.RouteToCluster)
}
- args = append(args, cf.RemainingArgs...)
+ args = append(args, *dbCmd.RemainingArgs...)
// Pass through the debug flag, and prepend to satisfy argument ordering
// needs (`-d` must precede `db`).
diff --git a/tool/tbot/init.go b/tool/tbot/init.go
index 91798ac5caeb9..04adddd620014 100644
--- a/tool/tbot/init.go
+++ b/tool/tbot/init.go
@@ -33,6 +33,7 @@ import (
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/lib/tbot/botfs"
+ "github.com/gravitational/teleport/lib/tbot/cli"
"github.com/gravitational/teleport/lib/tbot/config"
"github.com/gravitational/teleport/lib/tbot/identity"
)
@@ -372,16 +373,16 @@ func getOwner(cliOwner, defaultOwner string) (*user.User, *user.Group, error) {
// getAndTestACLOptions gets options needed to configure an ACL from CLI
// options and attempts to configure a test ACL to validate them. Ownership is
// not validated here.
-func getAndTestACLOptions(cf *config.CLIConf, destDir string) (*user.User, *user.Group, *botfs.ACLOptions, error) {
- if cf.BotUser == "" {
+func getAndTestACLOptions(initCmd *cli.InitCommand, destDir string) (*user.User, *user.Group, *botfs.ACLOptions, error) {
+ if initCmd.BotUser == "" {
return nil, nil, nil, trace.BadParameter("--bot-user must be set")
}
- if cf.ReaderUser == "" {
+ if initCmd.ReaderUser == "" {
return nil, nil, nil, trace.BadParameter("--reader-user must be set")
}
- botUser, err := user.Lookup(cf.BotUser)
+ botUser, err := user.Lookup(initCmd.BotUser)
if err != nil {
return nil, nil, nil, trace.Wrap(err)
}
@@ -391,7 +392,7 @@ func getAndTestACLOptions(cf *config.CLIConf, destDir string) (*user.User, *user
return nil, nil, nil, trace.Wrap(err)
}
- readerUser, err := user.Lookup(cf.ReaderUser)
+ readerUser, err := user.Lookup(initCmd.ReaderUser)
if err != nil {
return nil, nil, nil, trace.Wrap(err)
}
@@ -405,7 +406,7 @@ func getAndTestACLOptions(cf *config.CLIConf, destDir string) (*user.User, *user
// know the bot user definitely exists and is a reasonable owner choice.
defaultOwner := fmt.Sprintf("%s:%s", botUser.Username, botGroup.Name)
- ownerUser, ownerGroup, err := getOwner(cf.Owner, defaultOwner)
+ ownerUser, ownerGroup, err := getOwner(initCmd.Owner, defaultOwner)
if err != nil {
return nil, nil, nil, trace.Wrap(err)
}
@@ -420,17 +421,21 @@ func getAndTestACLOptions(cf *config.CLIConf, destDir string) (*user.User, *user
return ownerUser, ownerGroup, &opts, nil
}
-func onInit(botConfig *config.BotConfig, cf *config.CLIConf) error {
+func onInit(globals *cli.GlobalArgs, init *cli.InitCommand) error {
+ botConfig, err := cli.LoadConfigWithMutators(globals, init)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
- var err error
initables := botConfig.GetInitables()
var target config.Initable
// First, resolve the correct output/service. If using a config file with
// only 1 destination we can assume we want to init that one; otherwise,
// --init-dir is required.
- if cf.InitDir == "" {
+ if init.InitDir == "" {
if len(initables) == 1 {
target = initables[0]
} else {
@@ -440,13 +445,13 @@ func onInit(botConfig *config.BotConfig, cf *config.CLIConf) error {
for _, v := range initables {
d := v.GetDestination()
dirDest, ok := d.(*config.DestinationDirectory)
- if ok && dirDest.Path == cf.InitDir {
+ if ok && dirDest.Path == init.InitDir {
target = v
break
}
}
if target == nil {
- return trace.NotFound("Could not find specified destination %q", cf.InitDir)
+ return trace.NotFound("Could not find specified destination %q", init.InitDir)
}
}
@@ -474,7 +479,7 @@ func onInit(botConfig *config.BotConfig, cf *config.CLIConf) error {
log.DebugContext(ctx, "Testing for ACL support")
// Awkward control flow here, but we want these to fail together.
- ownerUser, ownerGroup, aclOpts, err = getAndTestACLOptions(cf, destDir.Path)
+ ownerUser, ownerGroup, aclOpts, err = getAndTestACLOptions(init, destDir.Path)
if err != nil {
if destDir.ACLs == botfs.ACLRequired {
// ACLs were specifically requested (vs "try" mode), so fail.
@@ -487,7 +492,7 @@ func onInit(botConfig *config.BotConfig, cf *config.CLIConf) error {
// We'll also need to re-fetch the owner as the defaults are
// different in the fallback case.
- ownerUser, ownerGroup, err = getOwner(cf.Owner, "")
+ ownerUser, ownerGroup, err = getOwner(init.Owner, "")
if err != nil {
return trace.Wrap(err)
}
@@ -501,7 +506,7 @@ func onInit(botConfig *config.BotConfig, cf *config.CLIConf) error {
}
default:
log.InfoContext(ctx, "ACLs disabled for this destination")
- ownerUser, ownerGroup, err = getOwner(cf.Owner, "")
+ ownerUser, ownerGroup, err = getOwner(init.Owner, "")
if err != nil {
return trace.Wrap(err)
}
@@ -534,7 +539,7 @@ func onInit(botConfig *config.BotConfig, cf *config.CLIConf) error {
}
// ... and warn about / remove any unneeded files.
- if len(toRemove) > 0 && cf.Clean {
+ if len(toRemove) > 0 && init.Clean {
log.InfoContext(ctx, "Attempting to remove", "path", toRemove)
var errors []error
diff --git a/tool/tbot/init_test.go b/tool/tbot/init_test.go
index 05ee64bc6d804..a9d6e8ef34ad3 100644
--- a/tool/tbot/init_test.go
+++ b/tool/tbot/init_test.go
@@ -30,9 +30,11 @@ import (
"github.com/gravitational/trace"
"github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
"github.com/gravitational/teleport/api/constants"
"github.com/gravitational/teleport/lib/tbot/botfs"
+ "github.com/gravitational/teleport/lib/tbot/cli"
"github.com/gravitational/teleport/lib/tbot/config"
"github.com/gravitational/teleport/lib/tbot/identity"
)
@@ -104,19 +106,24 @@ func getACLOptions() (*botfs.ACLOptions, error) {
}, nil
}
-// testConfigFromCLI creates a BotConfig from the given CLI config.
-func testConfigFromCLI(t *testing.T, cf *config.CLIConf) *config.BotConfig {
- cfg, err := config.FromCLIConf(cf)
+// testConfigFromString parses a YAML config file from a string.
+func testConfigFromString(t *testing.T, yamlStr string) *config.BotConfig {
+ // Load YAML to validate syntax.
+ cfg, err := config.ReadConfig(strings.NewReader(yamlStr), false)
require.NoError(t, err)
+ require.NoError(t, cfg.CheckAndSetDefaults())
- return cfg
-}
+ // Reencode as a string
+ out := &strings.Builder{}
+ enc := yaml.NewEncoder(out)
+ enc.SetIndent(2)
+ err = enc.Encode(cfg)
+ require.NoError(t, err)
-// testConfigFromString parses a YAML config file from a string.
-func testConfigFromString(t *testing.T, yaml string) *config.BotConfig {
- cfg, err := config.ReadConfig(strings.NewReader(yaml), false)
+ // Load and return the static config
+ globalArgs := cli.NewGlobalArgsWithStaticConfig(out.String())
+ cfg, err = cli.LoadConfigWithMutators(globalArgs)
require.NoError(t, err)
- require.NoError(t, cfg.CheckAndSetDefaults())
return cfg
}
@@ -144,14 +151,18 @@ func validateFileDestination(t *testing.T, svc config.Initable) *config.Destinat
// specified, this never tries to use ACLs.
func TestInit(t *testing.T) {
dir := t.TempDir()
- cf := &config.CLIConf{
- AuthServer: "example.com",
- DestinationDir: dir,
+ cmd := &cli.InitCommand{
+ LegacyDestinationDirArgs: &cli.LegacyDestinationDirArgs{
+ DestinationDir: dir,
+ },
+ AuthProxyArgs: cli.NewStaticAuthServer("example.com"),
}
- cfg := testConfigFromCLI(t, cf)
// Run init.
- require.NoError(t, onInit(cfg, cf))
+ require.NoError(t, onInit(&cli.GlobalArgs{}, cmd))
+
+ cfg, err := cli.LoadConfigWithMutators(&cli.GlobalArgs{}, cmd)
+ require.NoError(t, err)
// Make sure everything was created.
_ = validateFileDestination(t, cfg.GetInitables()[0])
@@ -185,20 +196,25 @@ func TestInitMaybeACLs(t *testing.T) {
// Note: we'll use the current user as owner as that's the only way to
// guarantee ACL write access.
dir := t.TempDir()
- cf := &config.CLIConf{
- AuthServer: "example.com",
- DestinationDir: dir,
- BotUser: opts.BotUser.Username,
- ReaderUser: opts.ReaderUser.Username,
+ cmd := &cli.InitCommand{
+ LegacyDestinationDirArgs: &cli.LegacyDestinationDirArgs{
+ DestinationDir: dir,
+ },
+ BotUser: opts.BotUser.Username,
+ ReaderUser: opts.ReaderUser.Username,
// This isn't a default, but unfortunately we need to specify a
// non-nobody owner for CI purposes.
Owner: fmt.Sprintf("%s:%s", currentUser.Username, currentGroup.Name),
+
+ AuthProxyArgs: cli.NewStaticAuthServer("example.com"),
}
- cfg := testConfigFromCLI(t, cf)
+
+ cfg, err := cli.LoadConfigWithMutators(&cli.GlobalArgs{}, cmd)
+ require.NoError(t, err)
// Run init.
- require.NoError(t, onInit(cfg, cf))
+ require.NoError(t, onInit(&cli.GlobalArgs{}, cmd))
// Make sure everything was created.
destDir := validateFileDestination(t, cfg.GetInitables()[0])
@@ -238,14 +254,18 @@ func TestInitSymlink(t *testing.T) {
require.NoError(t, os.Symlink(realPath, dataDir))
// Should fail due to symlink in path.
- cfg := testConfigFromString(t, fmt.Sprintf(testInitSymlinksTemplate, dataDir, botfs.SymlinksSecure))
- require.Error(t, onInit(cfg, &config.CLIConf{}))
+ cfgStr := fmt.Sprintf(testInitSymlinksTemplate, dataDir, botfs.SymlinksSecure)
+ globals := cli.NewGlobalArgsWithStaticConfig(cfgStr)
+ require.Error(t, onInit(globals, &cli.InitCommand{}))
// Should succeed when writing to the dir directly.
- cfg = testConfigFromString(t, fmt.Sprintf(testInitSymlinksTemplate, realPath, botfs.SymlinksSecure))
- require.NoError(t, onInit(cfg, &config.CLIConf{}))
+ cfgStr = fmt.Sprintf(testInitSymlinksTemplate, realPath, botfs.SymlinksSecure)
+ globals = cli.NewGlobalArgsWithStaticConfig(cfgStr)
+ require.NoError(t, onInit(globals, &cli.InitCommand{}))
- // Make sure everything was created.
+ // Make sure everything was created. We'll have to rebuild the config from
+ // scratch since we don't have a copy available.
+ cfg := testConfigFromString(t, cfgStr)
_ = validateFileDestination(t, cfg.GetInitables()[0])
}
@@ -258,6 +278,7 @@ func TestInitSymlinkInsecure(t *testing.T) {
require.NoError(t, os.Symlink(realPath, dataDir))
// Should succeed due to SymlinksInsecure
- cfg := testConfigFromString(t, fmt.Sprintf(testInitSymlinksTemplate, dataDir, botfs.SymlinksInsecure))
- require.Error(t, onInit(cfg, &config.CLIConf{}))
+
+ globals := cli.NewGlobalArgsWithStaticConfig(fmt.Sprintf(testInitSymlinksTemplate, dataDir, botfs.SymlinksInsecure))
+ require.Error(t, onInit(globals, &cli.InitCommand{}))
}
diff --git a/tool/tbot/kube.go b/tool/tbot/kube.go
index fbb366afea99f..2314ec83bc984 100644
--- a/tool/tbot/kube.go
+++ b/tool/tbot/kube.go
@@ -33,8 +33,8 @@ import (
clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
"github.com/gravitational/teleport/api/identityfile"
+ "github.com/gravitational/teleport/lib/tbot/cli"
"github.com/gravitational/teleport/lib/tbot/config"
- "github.com/gravitational/teleport/lib/tbot/tshwrap"
"github.com/gravitational/teleport/lib/tlsca"
)
@@ -65,8 +65,10 @@ func getCredentialData(idFile *identityfile.IdentityFile, currentTime time.Time)
return data, nil
}
-func onKubeCredentialsCommand(ctx context.Context, cfg *config.BotConfig) error {
- destination, err := tshwrap.GetDestinationDirectory(cfg)
+func onKubeCredentialsCommand(
+ ctx context.Context, kubeCredentialsCmd *cli.KubeCredentialsCommand,
+) error {
+ destination, err := config.DestinationFromURI(kubeCredentialsCmd.DestinationDir)
if err != nil {
return trace.Wrap(err)
}
diff --git a/tool/tbot/main.go b/tool/tbot/main.go
index bc09d9a089475..b73fc6c2c2b0e 100644
--- a/tool/tbot/main.go
+++ b/tool/tbot/main.go
@@ -27,7 +27,6 @@ import (
"runtime"
"runtime/pprof"
runtimetrace "runtime/trace"
- "strings"
"time"
"github.com/gravitational/trace"
@@ -38,6 +37,7 @@ import (
"github.com/gravitational/teleport/lib/modules"
"github.com/gravitational/teleport/lib/observability/tracing"
"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/tpm"
"github.com/gravitational/teleport/lib/utils"
@@ -46,12 +46,6 @@ import (
var log = logutils.NewPackageLogger(teleport.ComponentKey, teleport.ComponentTBot)
-const (
- authServerEnvVar = "TELEPORT_AUTH_SERVER"
- tokenEnvVar = "TELEPORT_BOT_TOKEN"
- proxyServerEnvVar = "TELEPORT_PROXY"
-)
-
func main() {
if err := Run(os.Args[1:], os.Stdout); err != nil {
utils.FatalError(err)
@@ -66,126 +60,90 @@ access Teleport protected resources in the same way your engineers do!
Find out more at https://goteleport.com/docs/machine-id/introduction/`
func Run(args []string, stdout io.Writer) error {
- var cf config.CLIConf
ctx := context.Background()
- var cpuProfile, memProfile, traceProfile string
+ var cpuProfile, memProfile, traceProfile, configureOutPath string
app := utils.InitCLIParser("tbot", appHelp).Interspersed(false)
- app.Flag("debug", "Verbose logging to stdout.").Short('d').BoolVar(&cf.Debug)
- app.Flag("config", "Path to a configuration file.").Short('c').StringVar(&cf.ConfigPath)
- app.Flag("fips", "Runs tbot in FIPS compliance mode. This requires the FIPS binary is in use.").BoolVar(&cf.FIPS)
- app.Flag("trace", "Capture and export distributed traces.").Hidden().BoolVar(&cf.Trace)
- app.Flag("trace-exporter", "An OTLP exporter URL to send spans to.").Hidden().StringVar(&cf.TraceExporter)
+ globalCfg := cli.NewGlobalArgs(app)
+
+ // Miscellaneous args exposed globally but handled here.
app.Flag("mem-profile", "Write memory profile to file").Hidden().StringVar(&memProfile)
app.Flag("cpu-profile", "Write CPU profile to file").Hidden().StringVar(&cpuProfile)
app.Flag("trace-profile", "Write trace profile to file").Hidden().StringVar(&traceProfile)
app.HelpFlag.Short('h')
- joinMethodList := fmt.Sprintf(
- "(%s)",
- strings.Join(config.SupportedJoinMethods, ", "),
- )
-
+ // Construct the top-level subcommands.
versionCmd := app.Command("version", "Print the version of your tbot binary.")
+ kubeCmd := app.Command("kube", "Kubernetes helpers").Hidden()
+
startCmd := app.Command("start", "Starts the renewal bot, writing certificates to the data dir at a set interval.")
- startCmd.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(authServerEnvVar).StringVar(&cf.AuthServer)
- startCmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(proxyServerEnvVar).StringVar(&cf.ProxyServer)
- startCmd.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(&cf.Token)
- startCmd.Flag("ca-pin", "CA pin to validate the Teleport Auth Server; used on first connect.").StringsVar(&cf.CAPins)
- startCmd.Flag("data-dir", "Directory to store internal bot data. Access to this directory should be limited.").StringVar(&cf.DataDir)
- startCmd.Flag("destination-dir", "Directory to write short-lived machine certificates.").StringVar(&cf.DestinationDir)
- startCmd.Flag("certificate-ttl", "TTL of short-lived machine certificates.").DurationVar(&cf.CertificateTTL)
- startCmd.Flag("renewal-interval", "Interval at which short-lived certificates are renewed; must be less than the certificate TTL.").DurationVar(&cf.RenewalInterval)
- startCmd.Flag("insecure", "Insecure configures the bot to trust the certificates from the Auth Server or Proxy on first connect without verification. Do not use in production.").BoolVar(&cf.Insecure)
- startCmd.Flag("join-method", "Method to use to join the cluster. "+joinMethodList).EnumVar(&cf.JoinMethod, config.SupportedJoinMethods...)
- startCmd.Flag("oneshot", "If set, quit after the first renewal.").BoolVar(&cf.Oneshot)
- startCmd.Flag("diag-addr", "If set and the bot is in debug mode, a diagnostics service will listen on specified address.").StringVar(&cf.DiagAddr)
- startCmd.Flag("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`.").
- Default(utils.LogFormatText).
- EnumVar(&cf.LogFormat, utils.LogFormatJSON, utils.LogFormatText)
-
- initCmd := app.Command("init", "Initialize a certificate destination directory for writes from a separate bot user.")
- initCmd.Flag("destination-dir", "Directory to write short-lived machine certificates to.").StringVar(&cf.DestinationDir)
- initCmd.Flag("owner", "Defines Linux \"user:group\" owner of \"--destination-dir\". Defaults to the Linux user running tbot if unspecified.").StringVar(&cf.Owner)
- initCmd.Flag("bot-user", "Enables POSIX ACLs and defines Linux user that can read/write short-lived certificates to \"--destination-dir\".").StringVar(&cf.BotUser)
- initCmd.Flag("reader-user", "Enables POSIX ACLs and defines Linux user that will read short-lived certificates from \"--destination-dir\".").StringVar(&cf.ReaderUser)
- initCmd.Flag("init-dir", "If using a config file and multiple destinations are configured, controls which destination dir to configure.").StringVar(&cf.InitDir)
- initCmd.Flag("clean", "If set, remove unexpected files and directories from the destination.").BoolVar(&cf.Clean)
- initCmd.Flag("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`.").
- Default(utils.LogFormatText).
- EnumVar(&cf.LogFormat, utils.LogFormatJSON, utils.LogFormatText)
configureCmd := app.Command("configure", "Creates a config file based on flags provided, and writes it to stdout or a file (-c ).")
- configureCmd.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(authServerEnvVar).StringVar(&cf.AuthServer)
- configureCmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(proxyServerEnvVar).StringVar(&cf.ProxyServer)
- configureCmd.Flag("ca-pin", "CA pin to validate the Teleport Auth Server; used on first connect.").StringsVar(&cf.CAPins)
- configureCmd.Flag("certificate-ttl", "TTL of short-lived machine certificates.").Default("60m").DurationVar(&cf.CertificateTTL)
- configureCmd.Flag("data-dir", "Directory to store internal bot data. Access to this directory should be limited.").StringVar(&cf.DataDir)
- configureCmd.Flag("insecure", "Insecure configures the bot to trust the certificates from the Auth Server or Proxy on first connect without verification. Do not use in production.").BoolVar(&cf.Insecure)
- configureCmd.Flag("join-method", "Method to use to join the cluster. "+joinMethodList).EnumVar(&cf.JoinMethod, config.SupportedJoinMethods...)
- configureCmd.Flag("oneshot", "If set, quit after the first renewal.").BoolVar(&cf.Oneshot)
- configureCmd.Flag("renewal-interval", "Interval at which short-lived certificates are renewed; must be less than the certificate TTL.").DurationVar(&cf.RenewalInterval)
- configureCmd.Flag("token", "A bot join token, if attempting to onboard a new bot; used on first connect.").Envar(tokenEnvVar).StringVar(&cf.Token)
- configureCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&cf.ConfigureOutput)
- configureCmd.Flag("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`.").
- Default(utils.LogFormatText).
- EnumVar(&cf.LogFormat, utils.LogFormatJSON, utils.LogFormatText)
-
- migrateCmd := app.Command("migrate", "Migrates a config file from an older version to the newest version. Outputs to stdout by default.")
- migrateCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&cf.ConfigureOutput)
-
- legacyProxyFlag := ""
-
- dbCmd := app.Command("db", "Execute database commands through tsh.")
- dbCmd.Flag("proxy-server", "The Teleport proxy server to use, in host:port form.").StringVar(&cf.ProxyServer)
- // We're migrating from --proxy to --proxy-server so this flag is hidden
- // but still supported.
- // TODO(strideynet): DELETE IN 17.0.0
- dbCmd.Flag("proxy", "The Teleport proxy server to use, in host:port form.").Hidden().Envar(proxyServerEnvVar).StringVar(&legacyProxyFlag)
- dbCmd.Flag("destination-dir", "The destination directory with which to authenticate tsh").StringVar(&cf.DestinationDir)
- dbCmd.Flag("cluster", "The cluster name. Extracted from the certificate if unset.").StringVar(&cf.Cluster)
- dbRemaining := config.RemainingArgs(dbCmd.Arg(
- "args",
- "Arguments to `tsh db ...`; prefix with `-- ` to ensure flags are passed correctly.",
- ))
-
- proxyCmd := app.Command("proxy", "Start a local TLS proxy via tsh to connect to Teleport in single-port mode.")
- proxyCmd.Flag("proxy-server", "The Teleport proxy server to use, in host:port form.").Envar(proxyServerEnvVar).StringVar(&cf.ProxyServer)
- // We're migrating from --proxy to --proxy-server so this flag is hidden
- // but still supported.
- // TODO(strideynet): DELETE IN 17.0.0
- proxyCmd.Flag("proxy", "The Teleport proxy server to use, in host:port form.").Hidden().StringVar(&legacyProxyFlag)
- proxyCmd.Flag("destination-dir", "The destination directory with which to authenticate tsh").StringVar(&cf.DestinationDir)
- proxyCmd.Flag("cluster", "The cluster name. Extracted from the certificate if unset.").StringVar(&cf.Cluster)
- proxyRemaining := config.RemainingArgs(proxyCmd.Arg(
- "args",
- "Arguments to `tsh proxy ...`; prefix with `-- ` to ensure flags are passed correctly.",
- ))
-
- sshProxyCmd := app.Command("ssh-proxy-command", "An OpenSSH/PuTTY proxy command").Hidden()
- sshProxyCmd.Flag("destination-dir", "The destination directory with which to authenticate tsh").StringVar(&cf.DestinationDir)
- sshProxyCmd.Flag("cluster", "The cluster name. Extracted from the certificate if unset.").StringVar(&cf.Cluster)
- sshProxyCmd.Flag("user", "The remote user name for the connection").Required().StringVar(&cf.User)
- sshProxyCmd.Flag("host", "The remote host to connect to").Required().StringVar(&cf.Host)
- sshProxyCmd.Flag("port", "The remote port to connect on.").StringVar(&cf.Port)
- sshProxyCmd.Flag("proxy-server", "The Teleport proxy server to use, in host:port form.").Required().StringVar(&cf.ProxyServer)
- sshProxyCmd.Flag("tls-routing", "Whether the Teleport cluster has tls routing enabled.").Required().BoolVar(&cf.TLSRoutingEnabled)
- sshProxyCmd.Flag("connection-upgrade", "Whether the Teleport cluster requires an ALPN connection upgrade.").Required().BoolVar(&cf.ConnectionUpgradeRequired)
- sshProxyCmd.Flag("proxy-templates", "The path to a file containing proxy templates to be evaluated.").StringVar(&cf.TSHConfigPath)
- sshProxyCmd.Flag("resume", "Enable SSH connection resumption").BoolVar(&cf.EnableResumption)
-
- sshMultiplexProxyCmd := app.Command("ssh-multiplexer-proxy-command", "An OpenSSH compatible ProxyCommand which connects to a long-lived tbot running the ssh-multiplexer service").Hidden()
- var sshMultiplexSocket string
- var sshMultiplexData string
- sshMultiplexProxyCmd.Arg("path", "Path to the listener socket.").Required().StringVar(&sshMultiplexSocket)
- sshMultiplexProxyCmd.Arg("data", "Connection target.").Required().StringVar(&sshMultiplexData)
+ configureCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&configureOutPath)
- kubeCmd := app.Command("kube", "Kubernetes helpers").Hidden()
- kubeCredentialsCmd := kubeCmd.Command("credentials", "Get credentials for kubectl access").Hidden()
- kubeCredentialsCmd.Flag("destination-dir", "The destination directory with which to generate Kubernetes credentials").Required().StringVar(&cf.DestinationDir)
+ // TODO: consider discarding config flag for non-legacy. These should always be self contained.
+
+ // Initialize all new-style commands.
+ var commands []cli.CommandRunner
+ commands = append(commands,
+ cli.NewInitCommand(app, func(init *cli.InitCommand) error {
+ return onInit(globalCfg, init)
+ }),
+
+ cli.NewMigrateCommand(app, func(migrateCfg *cli.MigrateCommand) error {
+ return onMigrate(ctx, globalCfg, migrateCfg, stdout)
+ }),
+
+ cli.NewSSHProxyCommand(app, func(sshProxyCommand *cli.SSHProxyCommand) error {
+ return onSSHProxyCommand(ctx, globalCfg, sshProxyCommand)
+ }),
+
+ cli.NewProxyCommand(app, func(proxyCmd *cli.ProxyCommand) error {
+ return onProxyCommand(ctx, globalCfg, proxyCmd)
+ }),
+
+ cli.NewDBCommand(app, func(dbCmd *cli.DBCommand) error {
+ return onDBCommand(globalCfg, dbCmd)
+ }),
+ cli.NewSSHMultiplexerProxyCommand(app, func(c *cli.SSHMultiplexerProxyCommand) error {
+ return onSSHMultiplexProxyCommand(ctx, c.Socket, c.Data)
+ }),
+
+ cli.NewKubeCredentialsCommand(kubeCmd, func(kubeCredentialsCmd *cli.KubeCredentialsCommand) error {
+ return onKubeCredentialsCommand(ctx, kubeCredentialsCmd)
+ }),
+
+ // `start` and `configure` commands
+ cli.NewLegacyCommand(startCmd, buildConfigAndStart(ctx, globalCfg)),
+ cli.NewLegacyCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)),
+
+ cli.NewIdentityCommand(startCmd, buildConfigAndStart(ctx, globalCfg)),
+ cli.NewIdentityCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)),
+
+ cli.NewDatabaseCommand(startCmd, buildConfigAndStart(ctx, globalCfg)),
+ cli.NewDatabaseCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)),
+
+ cli.NewKubernetesCommand(startCmd, buildConfigAndStart(ctx, globalCfg)),
+ cli.NewKubernetesCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)),
+
+ cli.NewApplicationCommand(startCmd, buildConfigAndStart(ctx, globalCfg)),
+ cli.NewApplicationCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)),
+
+ cli.NewApplicationTunnelCommand(startCmd, buildConfigAndStart(ctx, globalCfg)),
+ cli.NewApplicationTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)),
+
+ cli.NewDatabaseTunnelCommand(startCmd, buildConfigAndStart(ctx, globalCfg)),
+ cli.NewDatabaseTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)),
+
+ cli.NewSPIFFESVIDCommand(startCmd, buildConfigAndStart(ctx, globalCfg)),
+ cli.NewSPIFFESVIDCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)),
+ )
+
+ // Initialize legacy-style commands. These are simple enough to not really
+ // benefit from conversion to a new-style command.
spiffeInspectPath := ""
spiffeInspectCmd := app.Command("spiffe-inspect", "Inspects a SPIFFE Workload API endpoint to ensure it is working correctly.")
spiffeInspectCmd.Flag("path", "The path to the SPIFFE Workload API endpoint to test.").Required().StringVar(&spiffeInspectPath)
@@ -203,34 +161,17 @@ func Run(args []string, stdout io.Writer) error {
}
// Logging must be configured as early as possible to ensure all log
// message are formatted correctly.
- if err := setupLogger(cf.Debug, cf.LogFormat); err != nil {
+ if err := setupLogger(globalCfg.Debug, globalCfg.LogFormat); err != nil {
return trace.Wrap(err, "setting up logger")
}
- if legacyProxyFlag != "" {
- cf.ProxyServer = legacyProxyFlag
- log.WarnContext(
- ctx,
- "The --proxy flag is deprecated and will be removed in v17.0.0. Use --proxy-server instead",
- )
- }
-
- // Remaining args are stored directly to a []string rather than written to
- // a shared ref like most other kingpin args, so we'll need to manually
- // move them to the remaining args field.
- if len(*dbRemaining) > 0 {
- cf.RemainingArgs = *dbRemaining
- } else if len(*proxyRemaining) > 0 {
- cf.RemainingArgs = *proxyRemaining
- }
-
- if cf.Trace {
+ if globalCfg.Trace {
log.InfoContext(
ctx,
"Initializing tracing provider. Traces will be exported",
- "trace_exporter", cf.TraceExporter,
+ "trace_exporter", globalCfg.TraceExporter,
)
- tp, err := initializeTracing(ctx, cf.TraceExporter)
+ tp, err := initializeTracing(ctx, globalCfg.TraceExporter)
if err != nil {
return trace.Wrap(err, "initializing tracing")
}
@@ -293,11 +234,8 @@ func Run(args []string, stdout io.Writer) error {
defer runtimetrace.Stop()
}
- // Some commands do not need the full context of the config, so we'll
- // run these first.
+ // Manually attempt to run all old-style commands.
switch command {
- case migrateCmd.FullCommand():
- return onMigrate(ctx, cf, stdout)
case versionCmd.FullCommand():
return onVersion()
case spiffeInspectCmd.FullCommand():
@@ -307,41 +245,49 @@ func Run(args []string, stdout io.Writer) error {
if err != nil {
return trace.Wrap(err, "querying TPM")
}
- tpm.PrintQuery(query, cf.Debug, os.Stdout)
+ tpm.PrintQuery(query, globalCfg.Debug, os.Stdout)
return nil
- case configureCmd.FullCommand():
- return onConfigure(ctx, cf, stdout)
- case sshProxyCmd.FullCommand():
- return onSSHProxyCommand(ctx, &cf)
- case sshMultiplexProxyCmd.FullCommand():
- return onSSHMultiplexProxyCommand(ctx, sshMultiplexSocket, sshMultiplexData)
case installSystemdCmdStr:
- return installSystemdCmdFn(ctx, log, cf.ConfigPath, os.Executable, os.Stdout)
+ return installSystemdCmdFn(ctx, log, globalCfg.ConfigPath, os.Executable, os.Stdout)
}
- botConfig, err := config.FromCLIConf(&cf)
- if err != nil {
+ // Attempt to run each new-style command.
+ for _, cmd := range commands {
+ match, err := cmd.TryRun(command)
+ if !match {
+ continue
+ }
+
return trace.Wrap(err)
}
- // The rest of the commands rely on the full config
- switch command {
- case startCmd.FullCommand():
- err = onStart(ctx, botConfig)
- case initCmd.FullCommand():
- err = onInit(botConfig, &cf)
- case dbCmd.FullCommand():
- err = onDBCommand(botConfig, &cf)
- case proxyCmd.FullCommand():
- err = onProxyCommand(ctx, botConfig, &cf)
- case kubeCredentialsCmd.FullCommand():
- err = onKubeCredentialsCommand(ctx, botConfig)
- default:
- // This should only happen when there's a missing switch case above.
- err = trace.BadParameter("command %q not configured", command)
+ return trace.BadParameter("command %q not configured", command)
+}
+
+// buildConfigAndStart returns a MutatorAction that will generate a config and
+// run `onStart` with the result.
+func buildConfigAndStart(ctx context.Context, globals *cli.GlobalArgs) cli.MutatorAction {
+ return func(mutator cli.ConfigMutator) error {
+ cfg, err := cli.LoadConfigWithMutators(globals, mutator)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ return trace.Wrap(onStart(ctx, cfg))
}
+}
+
+// buildConfigAndConfigure returns a MutatorAction that will generate a config
+// and run `onConfigure` with the result.
+func buildConfigAndConfigure(ctx context.Context, globals *cli.GlobalArgs, outPath *string, stdout io.Writer) cli.MutatorAction {
+ return func(mutator cli.ConfigMutator) error {
+ cfg, err := cli.BaseConfigWithMutators(globals, mutator)
+ if err != nil {
+ return trace.Wrap(err)
+ }
- return err
+ return trace.Wrap(onConfigure(ctx, cfg, *outPath, stdout))
+ }
}
func initializeTracing(
@@ -373,11 +319,11 @@ func onVersion() error {
func onConfigure(
ctx context.Context,
- cf config.CLIConf,
+ cfg *config.BotConfig,
+ outPath string,
stdout io.Writer,
) error {
out := stdout
- outPath := cf.ConfigureOutput
if outPath != "" {
f, err := os.Create(outPath)
if err != nil {
@@ -387,13 +333,6 @@ func onConfigure(
out = f
}
- // We do not want to load an existing configuration file as this will cause
- // it to be merged with the provided flags and defaults.
- cf.ConfigPath = ""
- cfg, err := config.FromCLIConf(&cf)
- if err != nil {
- return nil
- }
// Ensure they have provided a join method to use in the configuration.
if cfg.Onboarding.JoinMethod == types.JoinMethodUnspecified {
return trace.BadParameter("join method must be provided")
@@ -422,17 +361,18 @@ func onConfigure(
func onMigrate(
ctx context.Context,
- cf config.CLIConf,
+ globalCfg *cli.GlobalArgs,
+ migrateCmd *cli.MigrateCommand,
stdout io.Writer,
) error {
- if cf.ConfigPath == "" {
+ if globalCfg.ConfigPath == "" {
return trace.BadParameter("source config file must be provided with -c")
}
out := stdout
- outPath := cf.ConfigureOutput
+ outPath := migrateCmd.ConfigureOutput
if outPath != "" {
- if outPath == cf.ConfigPath {
+ if outPath == globalCfg.ConfigPath {
return trace.BadParameter("migrated config output path should not be the same as the source config path")
}
@@ -446,7 +386,7 @@ func onMigrate(
// We do not want to load an existing configuration file as this will cause
// it to be merged with the provided flags and defaults.
- cfg, err := config.ReadConfigFromFile(cf.ConfigPath, true)
+ cfg, err := config.ReadConfigFromFile(globalCfg.ConfigPath, true)
if err != nil {
return trace.Wrap(err)
}
diff --git a/tool/tbot/proxy.go b/tool/tbot/proxy.go
index e566f733b517c..677a0caeb93a0 100644
--- a/tool/tbot/proxy.go
+++ b/tool/tbot/proxy.go
@@ -26,13 +26,19 @@ import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/lib/tbot/cli"
"github.com/gravitational/teleport/lib/tbot/config"
"github.com/gravitational/teleport/lib/tbot/tshwrap"
)
func onProxyCommand(
- ctx context.Context, botConfig *config.BotConfig, cf *config.CLIConf,
+ ctx context.Context, globalCfg *cli.GlobalArgs, proxyCmd *cli.ProxyCommand,
) error {
+ botConfig, err := cli.LoadConfigWithMutators(globalCfg)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
wrapper, err := tshwrap.New()
if err != nil {
return trace.Wrap(err)
@@ -52,8 +58,8 @@ func onProxyCommand(
// TODO(timothyb89): We could consider supporting a --cluster passthrough
// here as in `tbot db ...`.
- args := []string{"-i", identityPath, "proxy", "--proxy=" + cf.ProxyServer}
- args = append(args, cf.RemainingArgs...)
+ args := []string{"-i", identityPath, "proxy", "--proxy=" + proxyCmd.ProxyServer}
+ args = append(args, *proxyCmd.ProxyRemaining...)
// Pass through the debug flag, and prepend to satisfy argument ordering
// needs (`-d` must precede `proxy`).
@@ -63,7 +69,7 @@ func onProxyCommand(
// Handle a special case for `tbot proxy kube` where additional env vars
// need to be injected.
- if slices.Contains(cf.RemainingArgs, "kube") {
+ if slices.Contains(*proxyCmd.ProxyRemaining, "kube") {
// `tsh kube proxy` uses teleport.EnvKubeConfig to determine the
// original kube config file.
env[teleport.EnvKubeConfig] = filepath.Join(
@@ -75,7 +81,7 @@ func onProxyCommand(
destination.Path, "kubeconfig-proxied.yaml",
)
}
- if slices.Contains(cf.RemainingArgs, "ssh") {
+ if slices.Contains(*proxyCmd.ProxyRemaining, "ssh") {
log.WarnContext(ctx, "`tbot proxy ssh` is deprecated and will stop working in v17. See https://goteleport.com/docs/machine-id/reference/v16-upgrade-guide/")
}
diff --git a/tool/tbot/proxy_ssh.go b/tool/tbot/proxy_ssh.go
index 52928f3b8ac8c..f4bc17a0c7a09 100644
--- a/tool/tbot/proxy_ssh.go
+++ b/tool/tbot/proxy_ssh.go
@@ -23,7 +23,7 @@ import (
"github.com/gravitational/trace"
"github.com/gravitational/teleport/lib/tbot"
- "github.com/gravitational/teleport/lib/tbot/config"
+ "github.com/gravitational/teleport/lib/tbot/cli"
)
// onSSHProxyCommand is meant to be used as an OpenSSH/PuTTY proxy command. While this
@@ -31,27 +31,27 @@ import (
// `tsh proxy ssh` which results in much less memory and cpu consumption. This will
// eventually supersede `tbot proxy ssh` as it becomes more feature rich and supports
// all the edge cases.
-func onSSHProxyCommand(ctx context.Context, cf *config.CLIConf) error {
+func onSSHProxyCommand(ctx context.Context, globalCfg *cli.GlobalArgs, sshProxyCmd *cli.SSHProxyCommand) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
- if cf.Port == "" {
- cf.Port = "0"
+ if sshProxyCmd.Port == "" {
+ sshProxyCmd.Port = "0"
}
proxySSHConfig := tbot.ProxySSHConfig{
- DestinationPath: cf.DestinationDir,
- Insecure: cf.Insecure,
- FIPS: cf.FIPS,
- ProxyServer: cf.ProxyServer,
- Cluster: cf.Cluster,
- User: cf.User,
- Host: cf.Host,
- Port: cf.Port,
- EnableResumption: cf.EnableResumption,
- TLSRoutingEnabled: cf.TLSRoutingEnabled,
- ConnectionUpgradeRequired: cf.ConnectionUpgradeRequired,
- TSHConfigPath: cf.TSHConfigPath,
+ Insecure: globalCfg.Insecure,
+ FIPS: globalCfg.FIPS,
+ DestinationPath: sshProxyCmd.DestinationDir,
+ ProxyServer: sshProxyCmd.ProxyServer,
+ Cluster: sshProxyCmd.Cluster,
+ User: sshProxyCmd.User,
+ Host: sshProxyCmd.Host,
+ Port: sshProxyCmd.Port,
+ EnableResumption: sshProxyCmd.EnableResumption,
+ TLSRoutingEnabled: sshProxyCmd.TLSRoutingEnabled,
+ ConnectionUpgradeRequired: sshProxyCmd.ConnectionUpgradeRequired,
+ TSHConfigPath: sshProxyCmd.TSHConfigPath,
Log: log,
}