From 83f10035ff72e04694b98f3aa4457697e1ce154b Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Fri, 11 Oct 2024 02:22:34 -0600 Subject: [PATCH] Machine ID: Refactor `tbot` CLI (#47130) * Machine ID: Refactor `tbot` CLI This significantly refactors the tbot CLI. In addition to a major overhaul of our CLI config handling, it also exposes a number of new subcommands for starting more than just an identity output via pure CLI. * Move new CLI handling into lib/tbot/cli; add all new subcommands This moves the new CLI handling code into `lib/tbot/cli`, splits `cli.go` into several better organized files, and adds all missing subcommands for major tbot functionality. * Add sane global handling, convert most other commands to new style This introduces a dedicated global args struct and removes CLIConf. As most other non-start (and configure) commands depended on that effectively global namespace, this refactors all of them to use the new style with properly namespaced command structs. This also introduces a `genericExecutorHandler` helper to simplify subcommands that run an arbitrary action without modifying the config. This also solves a number of longstanding CLI handling bugs resulting from the shared arg namespace. A few args were moved to globals that should always have been exposed, so now things like `--log-format` will work consistently. A few commands were left unconverted since they don't make use of any of the global args or config loading machinery. They could be converted easily but are technically simpler as written. We may opt to convert them, or to do so later. Some headway on testing, but more work is needed. * First batch of tests This adds the first batch of tests on the new CLI along with a bunch of associated changes made while fixing these tests and others. This also splits off 2 new flag helpers: LegacyDestinationDirArgs and AuthProxyArgs. These are reusable embeddable structs to handle legacy-style --destination-dir behavior, and modern --auth-server and --proxy-server. `tbot init` and its tests had a subtle dependency old-style `--destination-dir` handling so we can now reuse that. There may be other subcommands that require something similar. * Remove outdated TODO * Restore TestConfigCLIOnlySample * Fix lints * Fix missing embedded flag init * Code review feedback * Add docstrings * Add unit tests for all "start" commands and globals Also includes a few minor fixes discovered in testing - DiagAddr was not handled properly in shared args, and removed an unused field. * Fix imports * Add helper for repetitive test * Fix command name * Adjust behaviour of `tbot kube credentials` command * Add tests for remaining commands * Appease linter * Add godocs --------- Co-authored-by: Noah Stride --- lib/tbot/cli/cli.go | 231 +++++++++++++ lib/tbot/cli/cli_test.go | 169 ++++++++++ lib/tbot/cli/db.go | 68 ++++ lib/tbot/cli/db_test.go | 48 +++ lib/tbot/cli/globals.go | 110 ++++++ lib/tbot/cli/globals_test.go | 65 ++++ lib/tbot/cli/init.go | 75 +++++ lib/tbot/cli/init_test.go | 48 +++ lib/tbot/cli/kube_credentials.go | 38 +++ lib/tbot/cli/kube_credentials_test.go | 40 +++ lib/tbot/cli/migrate.go | 40 +++ lib/tbot/cli/migrate_test.go | 40 +++ lib/tbot/cli/proxy.go | 70 ++++ lib/tbot/cli/proxy_test.go | 48 +++ lib/tbot/cli/ssh_multiplexer_proxy.go | 43 +++ lib/tbot/cli/ssh_multiplexer_proxy_test.go | 42 +++ lib/tbot/cli/ssh_proxy.go | 81 +++++ lib/tbot/cli/ssh_proxy_test.go | 56 ++++ lib/tbot/cli/start_application.go | 76 +++++ lib/tbot/cli/start_application_test.go | 60 ++++ lib/tbot/cli/start_application_tunnel.go | 68 ++++ lib/tbot/cli/start_application_tunnel_test.go | 57 ++++ lib/tbot/cli/start_database.go | 80 +++++ lib/tbot/cli/start_database_test.go | 66 ++++ lib/tbot/cli/start_database_tunnel.go | 73 ++++ lib/tbot/cli/start_database_tunnel_test.go | 61 ++++ lib/tbot/cli/start_identity.go | 73 ++++ lib/tbot/cli/start_identity_test.go | 81 +++++ lib/tbot/cli/start_kubernetes.go | 76 +++++ lib/tbot/cli/start_kubernetes_test.go | 62 ++++ lib/tbot/cli/start_legacy.go | 272 +++++++++++++++ lib/tbot/cli/start_legacy_test.go | 80 +++++ lib/tbot/cli/start_shared.go | 235 +++++++++++++ lib/tbot/cli/start_shared_test.go | 76 +++++ lib/tbot/cli/start_spiffe_svid.go | 89 +++++ lib/tbot/cli/start_spiffe_svid_test.go | 74 +++++ lib/tbot/config/config.go | 314 +----------------- lib/tbot/config/config_storage.go | 5 + lib/tbot/config/config_test.go | 47 +-- lib/tbot/config/service_database.go | 10 + tool/tbot/db.go | 18 +- tool/tbot/init.go | 35 +- tool/tbot/init_test.go | 79 +++-- tool/tbot/kube.go | 8 +- tool/tbot/main.go | 282 +++++++--------- tool/tbot/proxy.go | 16 +- tool/tbot/proxy_ssh.go | 32 +- 47 files changed, 3215 insertions(+), 602 deletions(-) create mode 100644 lib/tbot/cli/cli.go create mode 100644 lib/tbot/cli/cli_test.go create mode 100644 lib/tbot/cli/db.go create mode 100644 lib/tbot/cli/db_test.go create mode 100644 lib/tbot/cli/globals.go create mode 100644 lib/tbot/cli/globals_test.go create mode 100644 lib/tbot/cli/init.go create mode 100644 lib/tbot/cli/init_test.go create mode 100644 lib/tbot/cli/kube_credentials.go create mode 100644 lib/tbot/cli/kube_credentials_test.go create mode 100644 lib/tbot/cli/migrate.go create mode 100644 lib/tbot/cli/migrate_test.go create mode 100644 lib/tbot/cli/proxy.go create mode 100644 lib/tbot/cli/proxy_test.go create mode 100644 lib/tbot/cli/ssh_multiplexer_proxy.go create mode 100644 lib/tbot/cli/ssh_multiplexer_proxy_test.go create mode 100644 lib/tbot/cli/ssh_proxy.go create mode 100644 lib/tbot/cli/ssh_proxy_test.go create mode 100644 lib/tbot/cli/start_application.go create mode 100644 lib/tbot/cli/start_application_test.go create mode 100644 lib/tbot/cli/start_application_tunnel.go create mode 100644 lib/tbot/cli/start_application_tunnel_test.go create mode 100644 lib/tbot/cli/start_database.go create mode 100644 lib/tbot/cli/start_database_test.go create mode 100644 lib/tbot/cli/start_database_tunnel.go create mode 100644 lib/tbot/cli/start_database_tunnel_test.go create mode 100644 lib/tbot/cli/start_identity.go create mode 100644 lib/tbot/cli/start_identity_test.go create mode 100644 lib/tbot/cli/start_kubernetes.go create mode 100644 lib/tbot/cli/start_kubernetes_test.go create mode 100644 lib/tbot/cli/start_legacy.go create mode 100644 lib/tbot/cli/start_legacy_test.go create mode 100644 lib/tbot/cli/start_shared.go create mode 100644 lib/tbot/cli/start_shared_test.go create mode 100644 lib/tbot/cli/start_spiffe_svid.go create mode 100644 lib/tbot/cli/start_spiffe_svid_test.go diff --git a/lib/tbot/cli/cli.go b/lib/tbot/cli/cli.go new file mode 100644 index 00000000000..ff820fc0d0f --- /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 00000000000..64ce04a6705 --- /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 00000000000..f6ea819b659 --- /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 00000000000..641acc87fe4 --- /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 00000000000..dcd762920da --- /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 00000000000..06fdc5256a8 --- /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 00000000000..6183fd55639 --- /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 00000000000..af37c957bed --- /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 00000000000..1e83dda1b84 --- /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 00000000000..ed14feeb432 --- /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 00000000000..da445902083 --- /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 00000000000..ea3d5954671 --- /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 00000000000..556f793016b --- /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 00000000000..bcd65b99f11 --- /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 00000000000..d3757b59437 --- /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 00000000000..8179bc7f346 --- /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 00000000000..2db0bc2ee32 --- /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 00000000000..9818c3b52f6 --- /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 00000000000..2d3665cdd25 --- /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 00000000000..1d0b562621a --- /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 00000000000..2d6ea0541b7 --- /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 00000000000..049c18d9864 --- /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 00000000000..01bc0fd8fb6 --- /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 00000000000..89008d623ac --- /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 00000000000..be863f6a933 --- /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 00000000000..7e15dc5fe27 --- /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 00000000000..0898c998f1f --- /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 00000000000..606b07babc2 --- /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 00000000000..028a09fb649 --- /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 00000000000..183ae805b3b --- /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 00000000000..95f412bdf82 --- /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 00000000000..93a40a75b3d --- /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 00000000000..310405990c8 --- /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 00000000000..5ec8cbd2f3d --- /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 00000000000..fd602fdb5d2 --- /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 00000000000..94528318f55 --- /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 72e25cc513f..2d049abcfe6 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 d95950ee87d..11237a04921 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 123e1b6b6c8..9769b892530 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 dfa7ce9e665..764f5364c07 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 13f7e137978..e2fe32375a3 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 91798ac5cae..04adddd6200 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 05ee64bc6d8..a9d6e8ef34a 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,20 +106,25 @@ 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) - require.NoError(t, err) - - return cfg -} - // 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) +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()) + // Reencode as a string + out := &strings.Builder{} + enc := yaml.NewEncoder(out) + enc.SetIndent(2) + err = enc.Encode(cfg) + require.NoError(t, err) + + // Load and return the static config + globalArgs := cli.NewGlobalArgsWithStaticConfig(out.String()) + cfg, err = cli.LoadConfigWithMutators(globalArgs) + require.NoError(t, err) + 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 fbb366afea9..2314ec83bc9 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 bc09d9a0894..b73fc6c2c2b 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.") - 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) + kubeCmd := app.Command("kube", "Kubernetes helpers").Hidden() - 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) + startCmd := app.Command("start", "Starts the renewal bot, writing certificates to the data dir at a set interval.") 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) + configureCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&configureOutPath) - 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) + // TODO: consider discarding config flag for non-legacy. These should always be self contained. - legacyProxyFlag := "" + // Initialize all new-style commands. + var commands []cli.CommandRunner + commands = append(commands, + cli.NewInitCommand(app, func(init *cli.InitCommand) error { + return onInit(globalCfg, init) + }), - 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.", - )) + cli.NewMigrateCommand(app, func(migrateCfg *cli.MigrateCommand) error { + return onMigrate(ctx, globalCfg, migrateCfg, stdout) + }), - 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.", - )) + cli.NewSSHProxyCommand(app, func(sshProxyCommand *cli.SSHProxyCommand) error { + return onSSHProxyCommand(ctx, globalCfg, sshProxyCommand) + }), - 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) + cli.NewProxyCommand(app, func(proxyCmd *cli.ProxyCommand) error { + return onProxyCommand(ctx, globalCfg, proxyCmd) + }), - 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) + cli.NewDBCommand(app, func(dbCmd *cli.DBCommand) error { + return onDBCommand(globalCfg, dbCmd) + }), - 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) + 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) +} - return err +// 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 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 e566f733b51..677a0caeb93 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 52928f3b8ac..f4bc17a0c7a 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, }