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