From cc5abafbbf2c33c3c037b017d6b8086cf7c71a05 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Wed, 2 Oct 2024 21:26:20 -0600 Subject: [PATCH 01/18] 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. --- lib/tbot/config/cli.go | 579 ++++++++++++++++++++++++++++ lib/tbot/config/config.go | 113 ++++-- lib/tbot/config/service_database.go | 10 + tool/tbot/db.go | 7 +- tool/tbot/init.go | 8 +- tool/tbot/kube.go | 7 +- tool/tbot/main.go | 154 ++++---- tool/tbot/proxy.go | 7 +- 8 files changed, 779 insertions(+), 106 deletions(-) create mode 100644 lib/tbot/config/cli.go diff --git a/lib/tbot/config/cli.go b/lib/tbot/config/cli.go new file mode 100644 index 0000000000000..8b80cdc41a409 --- /dev/null +++ b/lib/tbot/config/cli.go @@ -0,0 +1,579 @@ +/* + * 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 config + +import ( + "context" + "fmt" + "log/slog" + "reflect" + "strings" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/trace" +) + +type globalArgs struct { + FIPS bool + + // These properties are not applied to the config. + + ConfigPath string + Debug bool +} + +func (g *globalArgs) ApplyConfig(cfg *BotConfig) error { + if g.FIPS { + cfg.FIPS = g.FIPS + } + + if g.Debug { + cfg.Debug = g.Debug + } + + return nil +} + +// sharedStartArgs are arguments that are shared between all modern `start` and +// `configure` subcommands. +type sharedStartArgs struct { + ProxyServer string + JoinMethod string + Insecure bool + Token string + CAPins []string + CertificateTTL time.Duration + RenewalInterval time.Duration + Storage string + + LogFormat string + Oneshot bool + DiagAddr string +} + +// newSharedStartArgs initializes shared arguments on the given parent command. +func newSharedStartArgs(cmd *kingpin.CmdClause) *sharedStartArgs { + args := &sharedStartArgs{} + + joinMethodList := fmt.Sprintf( + "(%s)", + strings.Join(SupportedJoinMethods, ", "), + ) + + cmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(proxyServerEnvVar).StringVar(&args.ProxyServer) + 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("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(&args.Insecure) + cmd.Flag("join-method", "Method to use to join the cluster. "+joinMethodList).EnumVar(&args.JoinMethod, 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("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`."). + Default(utils.LogFormatText). + EnumVar(&args.LogFormat, utils.LogFormatJSON, utils.LogFormatText) + cmd.Flag("storage", "A destination URI for tbot's internal storage.").StringVar(&args.Storage) + + return args +} + +func (s *sharedStartArgs) ApplyConfig(cfg *BotConfig, l *slog.Logger) error { + // TODO: Weird flags that need to be addressed: + // - Debug + // - FIPS + // - Insecure + + if s.Oneshot { + cfg.Oneshot = true + } + + // TODO: in previous versions, `insecure` is handled _after_ + // BotConfig.CheckAndSetDefaults(). This flag is checked and setting it here + // *will* cause a behavioral change, so make sure the new behavior is sane. + // (It is unclear why this was done.) + if s.Insecure { + cfg.Insecure = true + } + + if s.ProxyServer != "" { + if cfg.ProxyServer != "" { + l.WarnContext( + context.TODO(), + "CLI parameters are overriding configuration", + "flag", "proxy-server", + "config_value", cfg.ProxyServer, + "cli_value", s.ProxyServer, + ) + } + cfg.ProxyServer = s.ProxyServer + } + + 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 + } + + // 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 := destinationFromURI(s.Storage) + if err != nil { + return trace.Wrap(err) + } + cfg.Storage = &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, 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 = OnboardingConfig{ + CAPins: s.CAPins, + JoinMethod: types.JoinMethod(s.JoinMethod), + } + cfg.Onboarding.SetToken(s.Token) + } + + return nil +} + +// CommandStartLegacy starts with legacy behavior. This handles flags somewhat +// differently and maintains support for certain deprecated flags, so does not +// use `sharedStartArgs`. +type CommandStartLegacy struct { + // 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 + + // 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 + + // Destination is a destination URI + Destination 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 + + // ProxyServer is the teleport proxy address. Unlike `AuthServer` this must + // explicitly point to a Teleport proxy. + // Example: "example.teleport.sh:443" + ProxyServer string + + // 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 +} + +// NewLegacyCommand initializes and returns a command supporting +// `tbot start legacy` and `tbot configure legacy`. +func NewLegacyCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *CommandStartLegacy { + joinMethodList := fmt.Sprintf( + "(%s)", + strings.Join(SupportedJoinMethods, ", "), + ) + + c := &CommandStartLegacy{ + action: action, + cmd: parentCmd.Command("legacy", "Start with either a config file or a legacy output").Default(), + } + c.cmd.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(authServerEnvVar).StringVar(&c.AuthServer) + c.cmd.Flag("data-dir", "Directory to store internal bot data. Access to this directory should be limited.").StringVar(&c.DataDir) + c.cmd.Flag("destination-dir", "Directory to write short-lived machine certificates.").StringVar(&c.DestinationDir) + c.cmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(proxyServerEnvVar).StringVar(&c.ProxyServer) + 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("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(&c.Insecure) + c.cmd.Flag("join-method", "Method to use to join the cluster. "+joinMethodList).EnumVar(&c.JoinMethod, 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) + c.cmd.Flag("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`."). + Default(utils.LogFormatText). + EnumVar(&c.LogFormat, utils.LogFormatJSON, utils.LogFormatText) + + return c +} + +func (c *CommandStartLegacy) 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 *CommandStartLegacy) ApplyConfig(cfg *BotConfig, l *slog.Logger) error { + // TODO: Weird flags that need to be addressed: + // - Debug + // - FIPS + // - Insecure + + // if c.Debug { + // cfg.Debug = true + // } + + if c.Oneshot { + cfg.Oneshot = true + } + + if c.AuthServer != "" { + if cfg.AuthServer != "" { + log.WarnContext( + context.TODO(), + "CLI parameters are overriding configuration", + "flag", "auth-server", + "config_value", cfg.AuthServer, + "cli_value", c.AuthServer, + ) + } + cfg.AuthServer = c.AuthServer + } + + if c.ProxyServer != "" { + if cfg.ProxyServer != "" { + log.WarnContext( + context.TODO(), + "CLI parameters are overriding configuration", + "flag", "proxy-server", + "config_value", cfg.ProxyServer, + "cli_value", c.ProxyServer, + ) + } + cfg.ProxyServer = c.ProxyServer + } + + 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 := destinationFromURI(c.DataDir) + if err != nil { + return trace.Wrap(err) + } + cfg.Storage = &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, 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 = OnboardingConfig{ + CAPins: c.CAPins, + JoinMethod: types.JoinMethod(c.JoinMethod), + } + cfg.Onboarding.SetToken(c.Token) + } + + // TODO: + // if c.FIPS { + // cfg.FIPS = c.FIPS + // } + + 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 + } + + // TODO: This is now set _before_ CheckAndSetDefaults() which causes a mild + // change in behavior. Verify this is tolerable. + if c.Insecure { + cfg.Insecure = true + } + + return nil +} + +// MutatorAction is an action that is called by a config mutator-style command. +type MutatorAction func(mutator CLIConfigMutator) 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 CLIConfigMutator + action MutatorAction +} + +// newGenericMutatorHandler creates a new generic genericMutatorHandler that +// provides a generic `TryRun` implementation. +func newGenericMutatorHandler(cmd *kingpin.CmdClause, mutator CLIConfigMutator, 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) +} + +// CommandStartIdentity implements `tbot start identity` and +// `tbot configure identity`. +type CommandStartIdentity struct { + *sharedStartArgs + *genericMutatorHandler + + Destination string + Cluster string +} + +func NewIdentityCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *CommandStartIdentity { + cmd := parentCmd.Command("identity", "Start with an identity output for SSH and Teleport API access").Alias("ssh").Alias("id") + + c := &CommandStartIdentity{} + 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) + + // TODO: roles? ssh_config mode? + + return c +} + +func (c *CommandStartIdentity) ApplyConfig(cfg *BotConfig, l *slog.Logger) error { + if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil { + return trace.Wrap(err) + } + + dest, err := destinationFromURI(c.Destination) + if err != nil { + return trace.Wrap(err) + } + + cfg.Services = append(cfg.Services, &IdentityOutput{ + Destination: dest, + Cluster: c.Cluster, + }) + + return nil +} + +// CommandStartDatabase implements `tbot start database` and +// `tbot configure database`. +type CommandStartDatabase struct { + *sharedStartArgs + *genericMutatorHandler + + Destination string + Format string + Service string + Username string + Database string +} + +func NewStartDatabaseCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *CommandStartDatabase { + cmd := parentCmd.Command("database", "Starts with a database output").Alias("db") + + c := &CommandStartDatabase{} + 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, 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 *CommandStartDatabase) ApplyConfig(cfg *BotConfig, l *slog.Logger) error { + if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil { + return trace.Wrap(err) + } + + dest, err := destinationFromURI(c.Destination) + if err != nil { + return trace.Wrap(err) + } + + cfg.Services = append(cfg.Services, &DatabaseOutput{ + Destination: dest, + Format: DatabaseFormat(c.Format), + Username: c.Username, + Database: c.Database, + Service: c.Service, + }) + + return nil +} + +// CLICommandRunner defines a contract for `TryRun` that allows commands to +// either execute (possibly returning an error), or pass execution to the next +// command candidate. +type CLICommandRunner interface { + TryRun(cmd string) (match bool, err error) +} + +// CLIConfigMutator defines +type CLIConfigMutator interface { + ApplyConfig(cfg *BotConfig, l *slog.Logger) error +} diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go index 72e25cc513f24..9ab0d4a87355c 100644 --- a/lib/tbot/config/config.go +++ b/lib/tbot/config/config.go @@ -44,6 +44,10 @@ import ( const ( DefaultCertificateTTL = 60 * time.Minute DefaultRenewInterval = 20 * time.Minute + + authServerEnvVar = "TELEPORT_AUTH_SERVER" + tokenEnvVar = "TELEPORT_BOT_TOKEN" + proxyServerEnvVar = "TELEPORT_PROXY" ) var tracer = otel.Tracer("github.com/gravitational/teleport/lib/tbot/config") @@ -677,6 +681,7 @@ 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. +// TODO: Remove this function. func FromCLIConf(cf *CLIConf) (*BotConfig, error) { var config *BotConfig var err error @@ -703,8 +708,11 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { if config.AuthServer != "" { log.WarnContext( context.TODO(), - "CLI parameters are overriding destinations", + "CLI parameters are overriding configuration", "config_path", cf.ConfigPath, + "flag", "auth-server", + "config_value", config.AuthServer, + "cli_value", cf.AuthServer, ) } config.AuthServer = cf.AuthServer @@ -714,8 +722,11 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { if config.ProxyServer != "" { log.WarnContext( context.TODO(), - "CLI parameters are overriding destinations", + "CLI parameters are overriding configuration", "config_path", cf.ConfigPath, + "flag", "proxy-server", + "config_value", config.ProxyServer, + "cli_value", cf.ProxyServer, ) } config.ProxyServer = cf.ProxyServer @@ -725,8 +736,11 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { if config.CertificateTTL != 0 { log.WarnContext( context.TODO(), - "CLI parameters are overriding destinations", + "CLI parameters are overriding configuration", "config_path", cf.ConfigPath, + "flag", "certificate-ttl", + "config_value", config.CertificateTTL, + "cli_value", cf.CertificateTTL, ) } config.CertificateTTL = cf.CertificateTTL @@ -736,8 +750,11 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { if config.RenewalInterval != 0 { log.WarnContext( context.TODO(), - "CLI parameters are overriding destinations", + "CLI parameters are overriding configuration", "config_path", cf.ConfigPath, + "flag", "renewal-interval", + "config_value", config.RenewalInterval, + "cli_value", cf.RenewalInterval, ) } config.RenewalInterval = cf.RenewalInterval @@ -748,10 +765,14 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { if config.Storage != nil && config.Storage.Destination != nil { log.WarnContext( context.TODO(), - "CLI parameters are overriding destinations", + "CLI parameters are overriding configuration", "config_path", cf.ConfigPath, + "flag", "data-dir", + "config_value", config.Storage.Destination.String(), + "cli_value", cf.DataDir, ) } + dest, err := destinationFromURI(cf.DataDir) if err != nil { return nil, trace.Wrap(err) @@ -759,33 +780,6 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { 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 @@ -795,8 +789,11 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { // To be safe, warn about possible confusion. log.WarnContext( context.TODO(), - "CLI parameters are overriding destinations", + "CLI parameters are overriding join configuration", "config_path", cf.ConfigPath, + "cli_token", cf.Token, + "cli_join_method", cf.JoinMethod, + "cli_ca_pins_count", len(cf.CAPins), ) } @@ -815,8 +812,11 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { if config.DiagAddr != "" { log.WarnContext( context.TODO(), - "CLI parameters are overriding destinations", + "CLI parameters are overriding configuration", "config_path", cf.ConfigPath, + "flag", "diag-addr", + "config_value", config.DiagAddr, + "cli_value", cf.DiagAddr, ) } config.DiagAddr = cf.DiagAddr @@ -844,6 +844,51 @@ func ReadConfigFromFile(filePath string, manualMigration bool) (*BotConfig, erro return ReadConfig(f, manualMigration) } +// LoadConfigWithMutator 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. +// `CheckAndSetDefaults()` will be called on the end result. +func LoadConfigWithMutator(filePath string, mutator CLIConfigMutator) (*BotConfig, error) { + var cfg *BotConfig + var err error + + if filePath != "" { + cfg, err = ReadConfigFromFile(filePath, false) + + if err != nil { + return nil, trace.Wrap(err, "loading bot config from path %s", filePath) + } + } else { + cfg = &BotConfig{} + } + + l := log.With("config_path", filePath) + if err := mutator.ApplyConfig(cfg, l); err != nil { + return nil, trace.Wrap(err) + } + + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + return cfg, nil +} + +// BaseConfigWithMutator returns a base bot config with a given CLI mutator +// applied. `CheckAndSetDefaults()` will be called on the result. +func BaseConfigWithMutator(mutator CLIConfigMutator) (*BotConfig, error) { + cfg := &BotConfig{} + if err := mutator.ApplyConfig(cfg, log); err != nil { + return nil, trace.Wrap(err) + } + + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + return cfg, nil +} + type Version string var ( diff --git a/lib/tbot/config/service_database.go b/lib/tbot/config/service_database.go index dfa7ce9e66562..764f5364c07cd 100644 --- a/lib/tbot/config/service_database.go +++ b/lib/tbot/config/service_database.go @@ -196,3 +196,13 @@ func (o *DatabaseOutput) UnmarshalYAML(node *yaml.Node) error { func (o *DatabaseOutput) Type() string { return DatabaseOutputType } + +// SupportedDatabaseFormatStrings returns a constant list of all valid +// DatabaseFormat values as strings. +func SupportedDatabaseFormatStrings() (ret []string) { + for _, v := range databaseFormats { + ret = append(ret, string(v)) + } + + return +} diff --git a/tool/tbot/db.go b/tool/tbot/db.go index 13f7e13797833..7e3f4c08c062a 100644 --- a/tool/tbot/db.go +++ b/tool/tbot/db.go @@ -28,7 +28,12 @@ import ( "github.com/gravitational/teleport/lib/utils" ) -func onDBCommand(botConfig *config.BotConfig, cf *config.CLIConf) error { +func onDBCommand(cf *config.CLIConf) error { + botConfig, err := config.FromCLIConf(cf) + if err != nil { + return trace.Wrap(err) + } + wrapper, err := tshwrap.New() if err != nil { return trace.Wrap(err) diff --git a/tool/tbot/init.go b/tool/tbot/init.go index 91798ac5caeb9..369c376b3dae1 100644 --- a/tool/tbot/init.go +++ b/tool/tbot/init.go @@ -420,11 +420,15 @@ 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(cf *config.CLIConf) error { + botConfig, err := config.FromCLIConf(cf) + 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 diff --git a/tool/tbot/kube.go b/tool/tbot/kube.go index fbb366afea99f..f6fdff00cb580 100644 --- a/tool/tbot/kube.go +++ b/tool/tbot/kube.go @@ -65,7 +65,12 @@ func getCredentialData(idFile *identityfile.IdentityFile, currentTime time.Time) return data, nil } -func onKubeCredentialsCommand(ctx context.Context, cfg *config.BotConfig) error { +func onKubeCredentialsCommand(ctx context.Context, cf *config.CLIConf) error { + cfg, err := config.FromCLIConf(cf) + if err != nil { + return trace.Wrap(err) + } + destination, err := tshwrap.GetDestinationDirectory(cfg) if err != nil { return trace.Wrap(err) diff --git a/tool/tbot/main.go b/tool/tbot/main.go index bc09d9a089475..dece61b1b8739 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" @@ -69,7 +68,7 @@ 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) @@ -82,29 +81,53 @@ func Run(args []string, stdout io.Writer) error { app.Flag("trace-profile", "Write trace profile to file").Hidden().StringVar(&traceProfile) app.HelpFlag.Short('h') - joinMethodList := fmt.Sprintf( - "(%s)", - strings.Join(config.SupportedJoinMethods, ", "), - ) - 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) + configureCmd := app.Command("configure", "Creates a config file based on flags provided, and writes it to stdout or a file (-c ).") + configureCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&configureOutPath) + + var commands []config.CLICommandRunner + commands = append(commands, + config.NewLegacyCommand(startCmd, applyConfigAndStart(ctx, cf.ConfigPath)), + config.NewLegacyCommand(configureCmd, applyConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), + ) + + commands = append(commands, + config.NewIdentityCommand(startCmd, applyConfigAndStart(ctx, cf.ConfigPath)), + config.NewIdentityCommand(configureCmd, applyConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), + ) + + // startLegacy := startCmd.Command("legacy", "Start with either a config file or a legacy output").Default() + // startLegacy.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(authServerEnvVar).StringVar(&cf.AuthServer) + // startLegacy.Flag("data-dir", "Directory to store internal bot data. Access to this directory should be limited.").StringVar(&cf.DataDir) + // startLegacy.Flag("destination-dir", "Directory to write short-lived machine certificates.").StringVar(&cf.DestinationDir) + + // startIdentity := startCmd.Command("identity", "Start with an identity output for SSH and Teleport API access").Alias("ssh").Alias("id") + // startIdentity.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&cf.Destination) + // startIdentity.Flag("cluster", "The name of a specific cluster for which to issue an identity if using a leaf cluster").StringVar(&cf.Cluster) + // TODO: roles? ssh_config mode? + // TODO: storage? + + // startDatabase := startCmd.Command("database", "Starts with a database output").Alias("db") + // startDatabase.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&cf.Destination) + // startDatabase.Flag("format", "The database output format if necessary").Default("").EnumVar(&cf.DatabaseFormat, config.SupportedDatabaseFormatStrings()...) + // startDatabase.Flag("service", "The database service name").Required().StringVar(&cf.DatabaseService) + // startDatabase.Flag("username", "The database user name").Required().StringVar(&cf.DatabaseUsername) + // startDatabase.Flag("database", "The name of the database available in the requested database service").Required().StringVar(&cf.DatabaseDatabase) + // TODO: roles? + + // startApplication := startCmd.Command("application", "Starts with an application output").Alias("app") + // startApplication.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&cf.Destination) + + // startKubernetes := startCmd.Command("kubernetes", "Starts with a Kubernetes output").Alias("k8s") + // startKubernetes.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&cf.Destination) + + // startDatabaseTunnel := startCmd.Command("database-tunnel", "Starts a database tunnel").Alias("db-tunnel") + // startApplicationTunnel := startCmd.Command("application-tunnel", "Starts an app tunnel").Alias("app-tunnel") + // startSpiffeX509SVID := startCmd.Command("spiffe-x509-svid", "Starts with a SPIFFE X509 SVID output") + + // TODO: Should there be a workload id subcommand? 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) @@ -117,22 +140,6 @@ func Run(args []string, stdout io.Writer) error { Default(utils.LogFormatText). EnumVar(&cf.LogFormat, utils.LogFormatJSON, utils.LogFormatText) - configureCmd := app.Command("configure", "Creates a config file based on flags provided, and writes it to stdout or a file (-c ).") - configureCmd.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(authServerEnvVar).StringVar(&cf.AuthServer) - configureCmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(proxyServerEnvVar).StringVar(&cf.ProxyServer) - configureCmd.Flag("ca-pin", "CA pin to validate the Teleport Auth Server; used on first connect.").StringsVar(&cf.CAPins) - configureCmd.Flag("certificate-ttl", "TTL of short-lived machine certificates.").Default("60m").DurationVar(&cf.CertificateTTL) - configureCmd.Flag("data-dir", "Directory to store internal bot data. Access to this directory should be limited.").StringVar(&cf.DataDir) - configureCmd.Flag("insecure", "Insecure configures the bot to trust the certificates from the Auth Server or Proxy on first connect without verification. Do not use in production.").BoolVar(&cf.Insecure) - configureCmd.Flag("join-method", "Method to use to join the cluster. "+joinMethodList).EnumVar(&cf.JoinMethod, config.SupportedJoinMethods...) - configureCmd.Flag("oneshot", "If set, quit after the first renewal.").BoolVar(&cf.Oneshot) - configureCmd.Flag("renewal-interval", "Interval at which short-lived certificates are renewed; must be less than the certificate TTL.").DurationVar(&cf.RenewalInterval) - configureCmd.Flag("token", "A bot join token, if attempting to onboard a new bot; used on first connect.").Envar(tokenEnvVar).StringVar(&cf.Token) - configureCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&cf.ConfigureOutput) - configureCmd.Flag("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`."). - Default(utils.LogFormatText). - EnumVar(&cf.LogFormat, utils.LogFormatJSON, utils.LogFormatText) - migrateCmd := app.Command("migrate", "Migrates a config file from an older version to the newest version. Outputs to stdout by default.") migrateCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&cf.ConfigureOutput) @@ -309,39 +316,59 @@ func Run(args []string, stdout io.Writer) error { } tpm.PrintQuery(query, cf.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) + case initCmd.FullCommand(): + return onInit(&cf) + case dbCmd.FullCommand(): + return onDBCommand(&cf) + case proxyCmd.FullCommand(): + return onProxyCommand(ctx, &cf) + case kubeCredentialsCmd.FullCommand(): + return onKubeCredentialsCommand(ctx, &cf) } - 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) +} + +// applyConfigAndStart returns a MutatorAction that will generate a config and +// run `onStart` with the result. +func applyConfigAndStart(ctx context.Context, configPath string) config.MutatorAction { + return func(mutator config.CLIConfigMutator) error { + cfg, err := config.LoadConfigWithMutator(configPath, mutator) + if err != nil { + return trace.Wrap(err) + } + + return trace.Wrap(onStart(ctx, cfg)) } +} - return err +// applyConfigAndConfigure returns a MutatorAction that will generate a config +// and run `onConfigure` with the result. +func applyConfigAndConfigure(ctx context.Context, configPath string, outPath string, stdout io.Writer) config.MutatorAction { + return func(mutator config.CLIConfigMutator) error { + cfg, err := config.BaseConfigWithMutator(mutator) + if err != nil { + return trace.Wrap(err) + } + + return trace.Wrap(onConfigure(ctx, cfg, outPath, stdout)) + } } func initializeTracing( @@ -373,11 +400,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 +414,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") diff --git a/tool/tbot/proxy.go b/tool/tbot/proxy.go index e566f733b517c..d16c4bebfc65d 100644 --- a/tool/tbot/proxy.go +++ b/tool/tbot/proxy.go @@ -31,8 +31,13 @@ import ( ) func onProxyCommand( - ctx context.Context, botConfig *config.BotConfig, cf *config.CLIConf, + ctx context.Context, cf *config.CLIConf, ) error { + botConfig, err := config.FromCLIConf(cf) + if err != nil { + return trace.Wrap(err) + } + wrapper, err := tshwrap.New() if err != nil { return trace.Wrap(err) From cb7f744a4a79613dccd3e6e3fae0f15cb5490e46 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Thu, 3 Oct 2024 19:53:11 -0600 Subject: [PATCH 02/18] 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. --- lib/tbot/cli/application.go | 73 ++++ lib/tbot/cli/application_tunnel.go | 65 ++++ lib/tbot/cli/cli.go | 147 ++++++++ lib/tbot/cli/database.go | 77 ++++ lib/tbot/cli/database_tunnel.go | 72 ++++ lib/tbot/cli/identity.go | 70 ++++ lib/tbot/cli/kubernetes.go | 73 ++++ lib/tbot/cli/legacy.go | 278 ++++++++++++++ lib/tbot/cli/shared.go | 179 +++++++++ lib/tbot/cli/spiffe_x509_svid.go | 85 +++++ lib/tbot/config/cli.go | 579 ----------------------------- lib/tbot/config/config.go | 51 +-- lib/tbot/config/config_test.go | 2 +- tool/tbot/main.go | 72 ++-- 14 files changed, 1156 insertions(+), 667 deletions(-) create mode 100644 lib/tbot/cli/application.go create mode 100644 lib/tbot/cli/application_tunnel.go create mode 100644 lib/tbot/cli/cli.go create mode 100644 lib/tbot/cli/database.go create mode 100644 lib/tbot/cli/database_tunnel.go create mode 100644 lib/tbot/cli/identity.go create mode 100644 lib/tbot/cli/kubernetes.go create mode 100644 lib/tbot/cli/legacy.go create mode 100644 lib/tbot/cli/shared.go create mode 100644 lib/tbot/cli/spiffe_x509_svid.go delete mode 100644 lib/tbot/config/cli.go diff --git a/lib/tbot/cli/application.go b/lib/tbot/cli/application.go new file mode 100644 index 0000000000000..a56e8a734ea01 --- /dev/null +++ b/lib/tbot/cli/application.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/teleport/lib/tbot/config" + "github.com/gravitational/trace" +) + +// ApplicationCommand implements `tbot start application` and +// `tbot configure application`. +type ApplicationCommand struct { + *sharedStartArgs + *genericMutatorHandler + + Destination string + AppName string + SpecificTLSExtensions bool +} + +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").Required().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/application_tunnel.go b/lib/tbot/cli/application_tunnel.go new file mode 100644 index 0000000000000..c78836f834d04 --- /dev/null +++ b/lib/tbot/cli/application_tunnel.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 ( + "log/slog" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/trace" +) + +// ApplicationTunnelCommand implements `tbot start application-tunnel` and +// `tbot configure application-tunnel`. +type ApplicationTunnelCommand struct { + *sharedStartArgs + *genericMutatorHandler + + Listen string + AppName string +} + +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/cli.go b/lib/tbot/cli/cli.go new file mode 100644 index 0000000000000..0222f02d04069 --- /dev/null +++ b/lib/tbot/cli/cli.go @@ -0,0 +1,147 @@ +/* + * 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" + "github.com/gravitational/teleport/lib/tbot/config" + logutils "github.com/gravitational/teleport/lib/utils/log" + "github.com/gravitational/trace" +) + +const ( + authServerEnvVar = "TELEPORT_AUTH_SERVER" + tokenEnvVar = "TELEPORT_BOT_TOKEN" + proxyServerEnvVar = "TELEPORT_PROXY" +) + +var log = logutils.NewPackageLogger(teleport.ComponentKey, teleport.ComponentTBot) + +type globalArgs struct { + FIPS bool + + // These properties are not applied to the config. + + ConfigPath string + Debug bool +} + +func (g *globalArgs) ApplyConfig(cfg *config.BotConfig) error { + if g.FIPS { + cfg.FIPS = g.FIPS + } + + if g.Debug { + cfg.Debug = g.Debug + } + + return nil +} + +// 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) +} + +// 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) +} + +// ConfigMutator is an interface that can apply changes to a BotConfig. +type ConfigMutator interface { + ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error +} + +// LoadConfigWithMutator 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. +// `CheckAndSetDefaults()` will be called on the end result. +func LoadConfigWithMutator(filePath string, mutator ConfigMutator) (*config.BotConfig, error) { + var cfg *config.BotConfig + var err error + + if filePath != "" { + cfg, err = config.ReadConfigFromFile(filePath, false) + + if err != nil { + return nil, trace.Wrap(err, "loading bot config from path %s", filePath) + } + } else { + cfg = &config.BotConfig{} + } + + l := log.With("config_path", filePath) + if err := mutator.ApplyConfig(cfg, l); err != nil { + return nil, trace.Wrap(err) + } + + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + return cfg, nil +} + +// BaseConfigWithMutator returns a base bot config with a given CLI mutator +// applied. `CheckAndSetDefaults()` will be called on the result. This is useful +// for explicitly _not_ loading a config file, like in `tbot configure ...` +func BaseConfigWithMutator(mutator ConfigMutator) (*config.BotConfig, error) { + cfg := &config.BotConfig{} + if err := mutator.ApplyConfig(cfg, log); err != nil { + return nil, trace.Wrap(err) + } + + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + return cfg, nil +} diff --git a/lib/tbot/cli/database.go b/lib/tbot/cli/database.go new file mode 100644 index 0000000000000..7d36557c10d3e --- /dev/null +++ b/lib/tbot/cli/database.go @@ -0,0 +1,77 @@ +/* + * 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/trace" +) + +// DatabaseCommand implements `tbot start database` and +// `tbot configure database`. +type DatabaseCommand struct { + *sharedStartArgs + *genericMutatorHandler + + Destination string + Format string + Service string + Username string + Database string +} + +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/database_tunnel.go b/lib/tbot/cli/database_tunnel.go new file mode 100644 index 0000000000000..fdf12968bf095 --- /dev/null +++ b/lib/tbot/cli/database_tunnel.go @@ -0,0 +1,72 @@ +/* + * 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/trace" +) + +// 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", "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/identity.go b/lib/tbot/cli/identity.go new file mode 100644 index 0000000000000..b6a62a6e5874f --- /dev/null +++ b/lib/tbot/cli/identity.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 ( + "log/slog" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/trace" +) + +// CommandStartIdentity implements `tbot start identity` and +// `tbot configure identity`. +type CommandStartIdentity struct { + *sharedStartArgs + *genericMutatorHandler + + Destination string + Cluster string +} + +func NewIdentityCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *CommandStartIdentity { + cmd := parentCmd.Command("identity", "Start with an identity output for SSH and Teleport API access").Alias("ssh").Alias("id") + + c := &CommandStartIdentity{} + 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) + + // TODO: roles? ssh_config mode? + + return c +} + +func (c *CommandStartIdentity) 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/kubernetes.go b/lib/tbot/cli/kubernetes.go new file mode 100644 index 0000000000000..f2d7118d078cd --- /dev/null +++ b/lib/tbot/cli/kubernetes.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/teleport/lib/tbot/config" + "github.com/gravitational/trace" +) + +// KubernetesCommand implements `tbot start kubernetes` and +// `tbot configure kubernetes`. +type KubernetesCommand struct { + *sharedStartArgs + *genericMutatorHandler + + Destination string + KubernetesCluster string + DisableExecPlugin bool +} + +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/legacy.go b/lib/tbot/cli/legacy.go new file mode 100644 index 0000000000000..71e522793a67a --- /dev/null +++ b/lib/tbot/cli/legacy.go @@ -0,0 +1,278 @@ +/* + * 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/teleport/api/types" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/trace" +) + +// CommandStartLegacy starts with legacy behavior. This handles flags somewhat +// differently and maintains support for certain deprecated flags, so does not +// use `sharedStartArgs`. +type CommandStartLegacy struct { + // 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 + + // 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 + + // Destination is a destination URI + Destination 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 + + // ProxyServer is the teleport proxy address. Unlike `AuthServer` this must + // explicitly point to a Teleport proxy. + // Example: "example.teleport.sh:443" + ProxyServer string + + // 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 +} + +// NewLegacyCommand initializes and returns a command supporting +// `tbot start legacy` and `tbot configure legacy`. +func NewLegacyCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *CommandStartLegacy { + joinMethodList := fmt.Sprintf( + "(%s)", + strings.Join(config.SupportedJoinMethods, ", "), + ) + + c := &CommandStartLegacy{ + action: action, + cmd: parentCmd.Command("legacy", "Start with either a config file or a legacy output").Default(), + } + c.cmd.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(authServerEnvVar).StringVar(&c.AuthServer) + c.cmd.Flag("data-dir", "Directory to store internal bot data. Access to this directory should be limited.").StringVar(&c.DataDir) + c.cmd.Flag("destination-dir", "Directory to write short-lived machine certificates.").StringVar(&c.DestinationDir) + c.cmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(proxyServerEnvVar).StringVar(&c.ProxyServer) + 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("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(&c.Insecure) + 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) + c.cmd.Flag("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`."). + Default(utils.LogFormatText). + EnumVar(&c.LogFormat, utils.LogFormatJSON, utils.LogFormatText) + + return c +} + +func (c *CommandStartLegacy) 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 *CommandStartLegacy) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error { + // TODO: Weird flags that need to be addressed: + // - Debug + // - FIPS + // - Insecure + + // if c.Debug { + // cfg.Debug = true + // } + + if c.Oneshot { + cfg.Oneshot = true + } + + if c.AuthServer != "" { + if cfg.AuthServer != "" { + log.WarnContext( + context.TODO(), + "CLI parameters are overriding configuration", + "flag", "auth-server", + "config_value", cfg.AuthServer, + "cli_value", c.AuthServer, + ) + } + cfg.AuthServer = c.AuthServer + } + + if c.ProxyServer != "" { + if cfg.ProxyServer != "" { + log.WarnContext( + context.TODO(), + "CLI parameters are overriding configuration", + "flag", "proxy-server", + "config_value", cfg.ProxyServer, + "cli_value", c.ProxyServer, + ) + } + cfg.ProxyServer = c.ProxyServer + } + + 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) + } + + // TODO: + // if c.FIPS { + // cfg.FIPS = c.FIPS + // } + + 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 + } + + // TODO: This is now set _before_ CheckAndSetDefaults() which causes a mild + // change in behavior. Verify this is tolerable. + if c.Insecure { + cfg.Insecure = true + } + + return nil +} + +// MutatorAction is an action that is called by a config mutator-style command. +type MutatorAction func(mutator ConfigMutator) error diff --git a/lib/tbot/cli/shared.go b/lib/tbot/cli/shared.go new file mode 100644 index 0000000000000..26038fcdb2feb --- /dev/null +++ b/lib/tbot/cli/shared.go @@ -0,0 +1,179 @@ +/* + * 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/teleport/api/types" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/trace" +) + +// sharedStartArgs are arguments that are shared between all modern `start` and +// `configure` subcommands. +type sharedStartArgs struct { + ProxyServer string + JoinMethod string + Insecure bool + Token string + CAPins []string + CertificateTTL time.Duration + RenewalInterval time.Duration + Storage string + + LogFormat string + Oneshot bool + DiagAddr string +} + +// newSharedStartArgs initializes shared arguments on the given parent command. +func newSharedStartArgs(cmd *kingpin.CmdClause) *sharedStartArgs { + args := &sharedStartArgs{} + + joinMethodList := fmt.Sprintf( + "(%s)", + strings.Join(config.SupportedJoinMethods, ", "), + ) + + cmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(proxyServerEnvVar).StringVar(&args.ProxyServer) + 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("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(&args.Insecure) + 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("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`."). + Default(utils.LogFormatText). + EnumVar(&args.LogFormat, utils.LogFormatJSON, utils.LogFormatText) + 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 { + // TODO: Weird flags that need to be addressed: + // - Debug + // - FIPS + // - Insecure + + if s.Oneshot { + cfg.Oneshot = true + } + + // TODO: in previous versions, `insecure` is handled _after_ + // BotConfig.CheckAndSetDefaults(). This flag is checked and setting it here + // *will* cause a behavioral change, so make sure the new behavior is sane. + // (It is unclear why this was done.) + if s.Insecure { + cfg.Insecure = true + } + + if s.ProxyServer != "" { + if cfg.ProxyServer != "" { + l.WarnContext( + context.TODO(), + "CLI parameters are overriding configuration", + "flag", "proxy-server", + "config_value", cfg.ProxyServer, + "cli_value", s.ProxyServer, + ) + } + cfg.ProxyServer = s.ProxyServer + } + + 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 + } + + // 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/spiffe_x509_svid.go b/lib/tbot/cli/spiffe_x509_svid.go new file mode 100644 index 0000000000000..cd3d8ec5bb46e --- /dev/null +++ b/lib/tbot/cli/spiffe_x509_svid.go @@ -0,0 +1,85 @@ +/* + * 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/trace" +) + +// SPIFFEX509SVIDCommand implements `tbot start spiffe-x509-svid` and +// `tbot configure spiffe-x509-svid`. +type SPIFFEX509SVIDCommand struct { + *sharedStartArgs + *genericMutatorHandler + + Destination string + IncludeFederatedTrustBundles bool + + SVIDPath string + SVIDHint string + DNSSANs []string + IPSANs []string +} + +func NewSPIFFEX509SVIDCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *SPIFFEX509SVIDCommand { + cmd := parentCmd.Command("spiffe-x509-svid", "Starts with a SPIFFE-compatible X509 SVID output") + + c := &SPIFFEX509SVIDCommand{} + 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("request-dns-san", "A DNS name that should be included in the SVID. Repeatable.").StringsVar(&c.DNSSANs) + cmd.Flag("request-ip-san", "An IP address that should be included in the SVID. Repeatable.").StringsVar(&c.IPSANs) + + return c +} + +func (c *SPIFFEX509SVIDCommand) 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/config/cli.go b/lib/tbot/config/cli.go deleted file mode 100644 index 8b80cdc41a409..0000000000000 --- a/lib/tbot/config/cli.go +++ /dev/null @@ -1,579 +0,0 @@ -/* - * 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 config - -import ( - "context" - "fmt" - "log/slog" - "reflect" - "strings" - "time" - - "github.com/alecthomas/kingpin/v2" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/lib/utils" - "github.com/gravitational/trace" -) - -type globalArgs struct { - FIPS bool - - // These properties are not applied to the config. - - ConfigPath string - Debug bool -} - -func (g *globalArgs) ApplyConfig(cfg *BotConfig) error { - if g.FIPS { - cfg.FIPS = g.FIPS - } - - if g.Debug { - cfg.Debug = g.Debug - } - - return nil -} - -// sharedStartArgs are arguments that are shared between all modern `start` and -// `configure` subcommands. -type sharedStartArgs struct { - ProxyServer string - JoinMethod string - Insecure bool - Token string - CAPins []string - CertificateTTL time.Duration - RenewalInterval time.Duration - Storage string - - LogFormat string - Oneshot bool - DiagAddr string -} - -// newSharedStartArgs initializes shared arguments on the given parent command. -func newSharedStartArgs(cmd *kingpin.CmdClause) *sharedStartArgs { - args := &sharedStartArgs{} - - joinMethodList := fmt.Sprintf( - "(%s)", - strings.Join(SupportedJoinMethods, ", "), - ) - - cmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(proxyServerEnvVar).StringVar(&args.ProxyServer) - 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("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(&args.Insecure) - cmd.Flag("join-method", "Method to use to join the cluster. "+joinMethodList).EnumVar(&args.JoinMethod, 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("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`."). - Default(utils.LogFormatText). - EnumVar(&args.LogFormat, utils.LogFormatJSON, utils.LogFormatText) - cmd.Flag("storage", "A destination URI for tbot's internal storage.").StringVar(&args.Storage) - - return args -} - -func (s *sharedStartArgs) ApplyConfig(cfg *BotConfig, l *slog.Logger) error { - // TODO: Weird flags that need to be addressed: - // - Debug - // - FIPS - // - Insecure - - if s.Oneshot { - cfg.Oneshot = true - } - - // TODO: in previous versions, `insecure` is handled _after_ - // BotConfig.CheckAndSetDefaults(). This flag is checked and setting it here - // *will* cause a behavioral change, so make sure the new behavior is sane. - // (It is unclear why this was done.) - if s.Insecure { - cfg.Insecure = true - } - - if s.ProxyServer != "" { - if cfg.ProxyServer != "" { - l.WarnContext( - context.TODO(), - "CLI parameters are overriding configuration", - "flag", "proxy-server", - "config_value", cfg.ProxyServer, - "cli_value", s.ProxyServer, - ) - } - cfg.ProxyServer = s.ProxyServer - } - - 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 - } - - // 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 := destinationFromURI(s.Storage) - if err != nil { - return trace.Wrap(err) - } - cfg.Storage = &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, 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 = OnboardingConfig{ - CAPins: s.CAPins, - JoinMethod: types.JoinMethod(s.JoinMethod), - } - cfg.Onboarding.SetToken(s.Token) - } - - return nil -} - -// CommandStartLegacy starts with legacy behavior. This handles flags somewhat -// differently and maintains support for certain deprecated flags, so does not -// use `sharedStartArgs`. -type CommandStartLegacy struct { - // 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 - - // 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 - - // Destination is a destination URI - Destination 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 - - // ProxyServer is the teleport proxy address. Unlike `AuthServer` this must - // explicitly point to a Teleport proxy. - // Example: "example.teleport.sh:443" - ProxyServer string - - // 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 -} - -// NewLegacyCommand initializes and returns a command supporting -// `tbot start legacy` and `tbot configure legacy`. -func NewLegacyCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *CommandStartLegacy { - joinMethodList := fmt.Sprintf( - "(%s)", - strings.Join(SupportedJoinMethods, ", "), - ) - - c := &CommandStartLegacy{ - action: action, - cmd: parentCmd.Command("legacy", "Start with either a config file or a legacy output").Default(), - } - c.cmd.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(authServerEnvVar).StringVar(&c.AuthServer) - c.cmd.Flag("data-dir", "Directory to store internal bot data. Access to this directory should be limited.").StringVar(&c.DataDir) - c.cmd.Flag("destination-dir", "Directory to write short-lived machine certificates.").StringVar(&c.DestinationDir) - c.cmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(proxyServerEnvVar).StringVar(&c.ProxyServer) - 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("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(&c.Insecure) - c.cmd.Flag("join-method", "Method to use to join the cluster. "+joinMethodList).EnumVar(&c.JoinMethod, 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) - c.cmd.Flag("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`."). - Default(utils.LogFormatText). - EnumVar(&c.LogFormat, utils.LogFormatJSON, utils.LogFormatText) - - return c -} - -func (c *CommandStartLegacy) 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 *CommandStartLegacy) ApplyConfig(cfg *BotConfig, l *slog.Logger) error { - // TODO: Weird flags that need to be addressed: - // - Debug - // - FIPS - // - Insecure - - // if c.Debug { - // cfg.Debug = true - // } - - if c.Oneshot { - cfg.Oneshot = true - } - - if c.AuthServer != "" { - if cfg.AuthServer != "" { - log.WarnContext( - context.TODO(), - "CLI parameters are overriding configuration", - "flag", "auth-server", - "config_value", cfg.AuthServer, - "cli_value", c.AuthServer, - ) - } - cfg.AuthServer = c.AuthServer - } - - if c.ProxyServer != "" { - if cfg.ProxyServer != "" { - log.WarnContext( - context.TODO(), - "CLI parameters are overriding configuration", - "flag", "proxy-server", - "config_value", cfg.ProxyServer, - "cli_value", c.ProxyServer, - ) - } - cfg.ProxyServer = c.ProxyServer - } - - 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 := destinationFromURI(c.DataDir) - if err != nil { - return trace.Wrap(err) - } - cfg.Storage = &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, 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 = OnboardingConfig{ - CAPins: c.CAPins, - JoinMethod: types.JoinMethod(c.JoinMethod), - } - cfg.Onboarding.SetToken(c.Token) - } - - // TODO: - // if c.FIPS { - // cfg.FIPS = c.FIPS - // } - - 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 - } - - // TODO: This is now set _before_ CheckAndSetDefaults() which causes a mild - // change in behavior. Verify this is tolerable. - if c.Insecure { - cfg.Insecure = true - } - - return nil -} - -// MutatorAction is an action that is called by a config mutator-style command. -type MutatorAction func(mutator CLIConfigMutator) 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 CLIConfigMutator - action MutatorAction -} - -// newGenericMutatorHandler creates a new generic genericMutatorHandler that -// provides a generic `TryRun` implementation. -func newGenericMutatorHandler(cmd *kingpin.CmdClause, mutator CLIConfigMutator, 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) -} - -// CommandStartIdentity implements `tbot start identity` and -// `tbot configure identity`. -type CommandStartIdentity struct { - *sharedStartArgs - *genericMutatorHandler - - Destination string - Cluster string -} - -func NewIdentityCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *CommandStartIdentity { - cmd := parentCmd.Command("identity", "Start with an identity output for SSH and Teleport API access").Alias("ssh").Alias("id") - - c := &CommandStartIdentity{} - 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) - - // TODO: roles? ssh_config mode? - - return c -} - -func (c *CommandStartIdentity) ApplyConfig(cfg *BotConfig, l *slog.Logger) error { - if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil { - return trace.Wrap(err) - } - - dest, err := destinationFromURI(c.Destination) - if err != nil { - return trace.Wrap(err) - } - - cfg.Services = append(cfg.Services, &IdentityOutput{ - Destination: dest, - Cluster: c.Cluster, - }) - - return nil -} - -// CommandStartDatabase implements `tbot start database` and -// `tbot configure database`. -type CommandStartDatabase struct { - *sharedStartArgs - *genericMutatorHandler - - Destination string - Format string - Service string - Username string - Database string -} - -func NewStartDatabaseCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *CommandStartDatabase { - cmd := parentCmd.Command("database", "Starts with a database output").Alias("db") - - c := &CommandStartDatabase{} - 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, 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 *CommandStartDatabase) ApplyConfig(cfg *BotConfig, l *slog.Logger) error { - if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil { - return trace.Wrap(err) - } - - dest, err := destinationFromURI(c.Destination) - if err != nil { - return trace.Wrap(err) - } - - cfg.Services = append(cfg.Services, &DatabaseOutput{ - Destination: dest, - Format: DatabaseFormat(c.Format), - Username: c.Username, - Database: c.Database, - Service: c.Service, - }) - - return nil -} - -// CLICommandRunner defines a contract for `TryRun` that allows commands to -// either execute (possibly returning an error), or pass execution to the next -// command candidate. -type CLICommandRunner interface { - TryRun(cmd string) (match bool, err error) -} - -// CLIConfigMutator defines -type CLIConfigMutator interface { - ApplyConfig(cfg *BotConfig, l *slog.Logger) error -} diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go index 9ab0d4a87355c..c7fbb59e51444 100644 --- a/lib/tbot/config/config.go +++ b/lib/tbot/config/config.go @@ -626,7 +626,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") @@ -773,7 +775,7 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { ) } - dest, err := destinationFromURI(cf.DataDir) + dest, err := DestinationFromURI(cf.DataDir) if err != nil { return nil, trace.Wrap(err) } @@ -844,51 +846,6 @@ func ReadConfigFromFile(filePath string, manualMigration bool) (*BotConfig, erro return ReadConfig(f, manualMigration) } -// LoadConfigWithMutator 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. -// `CheckAndSetDefaults()` will be called on the end result. -func LoadConfigWithMutator(filePath string, mutator CLIConfigMutator) (*BotConfig, error) { - var cfg *BotConfig - var err error - - if filePath != "" { - cfg, err = ReadConfigFromFile(filePath, false) - - if err != nil { - return nil, trace.Wrap(err, "loading bot config from path %s", filePath) - } - } else { - cfg = &BotConfig{} - } - - l := log.With("config_path", filePath) - if err := mutator.ApplyConfig(cfg, l); err != nil { - return nil, trace.Wrap(err) - } - - if err := cfg.CheckAndSetDefaults(); err != nil { - return nil, trace.Wrap(err) - } - - return cfg, nil -} - -// BaseConfigWithMutator returns a base bot config with a given CLI mutator -// applied. `CheckAndSetDefaults()` will be called on the result. -func BaseConfigWithMutator(mutator CLIConfigMutator) (*BotConfig, error) { - cfg := &BotConfig{} - if err := mutator.ApplyConfig(cfg, log); err != nil { - return nil, trace.Wrap(err) - } - - if err := cfg.CheckAndSetDefaults(); err != nil { - return nil, trace.Wrap(err) - } - - return cfg, nil -} - type Version string var ( diff --git a/lib/tbot/config/config_test.go b/lib/tbot/config/config_test.go index 123e1b6b6c8e3..2aa2515498d76 100644 --- a/lib/tbot/config/config_test.go +++ b/lib/tbot/config/config_test.go @@ -214,7 +214,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/tool/tbot/main.go b/tool/tbot/main.go index dece61b1b8739..a330b09888dfe 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -37,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" @@ -87,47 +88,38 @@ func Run(args []string, stdout io.Writer) error { configureCmd := app.Command("configure", "Creates a config file based on flags provided, and writes it to stdout or a file (-c ).") configureCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&configureOutPath) - var commands []config.CLICommandRunner - commands = append(commands, - config.NewLegacyCommand(startCmd, applyConfigAndStart(ctx, cf.ConfigPath)), - config.NewLegacyCommand(configureCmd, applyConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), - ) + // TODO: configureOutPath may have new arg positioning semantics. Verify any change and consider promoting to global + // if necessary. + // TODO: consider discarding config flag for non-legacy. These should always be self contained. + var commands []cli.CommandRunner commands = append(commands, - config.NewIdentityCommand(startCmd, applyConfigAndStart(ctx, cf.ConfigPath)), - config.NewIdentityCommand(configureCmd, applyConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), - ) + cli.NewLegacyCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), + cli.NewLegacyCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), - // startLegacy := startCmd.Command("legacy", "Start with either a config file or a legacy output").Default() - // startLegacy.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(authServerEnvVar).StringVar(&cf.AuthServer) - // startLegacy.Flag("data-dir", "Directory to store internal bot data. Access to this directory should be limited.").StringVar(&cf.DataDir) - // startLegacy.Flag("destination-dir", "Directory to write short-lived machine certificates.").StringVar(&cf.DestinationDir) + cli.NewIdentityCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), + cli.NewIdentityCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), - // startIdentity := startCmd.Command("identity", "Start with an identity output for SSH and Teleport API access").Alias("ssh").Alias("id") - // startIdentity.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&cf.Destination) - // startIdentity.Flag("cluster", "The name of a specific cluster for which to issue an identity if using a leaf cluster").StringVar(&cf.Cluster) - // TODO: roles? ssh_config mode? - // TODO: storage? + cli.NewDatabaseCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), + cli.NewDatabaseCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), - // startDatabase := startCmd.Command("database", "Starts with a database output").Alias("db") - // startDatabase.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&cf.Destination) - // startDatabase.Flag("format", "The database output format if necessary").Default("").EnumVar(&cf.DatabaseFormat, config.SupportedDatabaseFormatStrings()...) - // startDatabase.Flag("service", "The database service name").Required().StringVar(&cf.DatabaseService) - // startDatabase.Flag("username", "The database user name").Required().StringVar(&cf.DatabaseUsername) - // startDatabase.Flag("database", "The name of the database available in the requested database service").Required().StringVar(&cf.DatabaseDatabase) - // TODO: roles? + cli.NewKubernetesCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), + cli.NewKubernetesCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), - // startApplication := startCmd.Command("application", "Starts with an application output").Alias("app") - // startApplication.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&cf.Destination) + cli.NewApplicationCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), + cli.NewApplicationCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), - // startKubernetes := startCmd.Command("kubernetes", "Starts with a Kubernetes output").Alias("k8s") - // startKubernetes.Flag("destination", "A destination URI, such as file:///foo/bar").Required().StringVar(&cf.Destination) + cli.NewApplicationTunnelCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), + cli.NewApplicationTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), - // startDatabaseTunnel := startCmd.Command("database-tunnel", "Starts a database tunnel").Alias("db-tunnel") - // startApplicationTunnel := startCmd.Command("application-tunnel", "Starts an app tunnel").Alias("app-tunnel") - // startSpiffeX509SVID := startCmd.Command("spiffe-x509-svid", "Starts with a SPIFFE X509 SVID output") + cli.NewDatabaseTunnelCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), + cli.NewDatabaseTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), + + cli.NewSPIFFEX509SVIDCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), + cli.NewSPIFFEX509SVIDCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), + ) - // TODO: Should there be a workload id subcommand? + // TODO: workload id / spiffe service subcommand? 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) @@ -345,11 +337,11 @@ func Run(args []string, stdout io.Writer) error { return trace.BadParameter("command %q not configured", command) } -// applyConfigAndStart returns a MutatorAction that will generate a config and +// buildConfigAndStart returns a MutatorAction that will generate a config and // run `onStart` with the result. -func applyConfigAndStart(ctx context.Context, configPath string) config.MutatorAction { - return func(mutator config.CLIConfigMutator) error { - cfg, err := config.LoadConfigWithMutator(configPath, mutator) +func buildConfigAndStart(ctx context.Context, configPath string) cli.MutatorAction { + return func(mutator cli.ConfigMutator) error { + cfg, err := cli.LoadConfigWithMutator(configPath, mutator) if err != nil { return trace.Wrap(err) } @@ -358,11 +350,11 @@ func applyConfigAndStart(ctx context.Context, configPath string) config.MutatorA } } -// applyConfigAndConfigure returns a MutatorAction that will generate a config +// buildConfigAndConfigure returns a MutatorAction that will generate a config // and run `onConfigure` with the result. -func applyConfigAndConfigure(ctx context.Context, configPath string, outPath string, stdout io.Writer) config.MutatorAction { - return func(mutator config.CLIConfigMutator) error { - cfg, err := config.BaseConfigWithMutator(mutator) +func buildConfigAndConfigure(ctx context.Context, configPath string, outPath string, stdout io.Writer) cli.MutatorAction { + return func(mutator cli.ConfigMutator) error { + cfg, err := cli.BaseConfigWithMutator(mutator) if err != nil { return trace.Wrap(err) } From 94435317b03f11fa5f93f5d676b4af145c8deebc Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Fri, 4 Oct 2024 20:20:20 -0600 Subject: [PATCH 03/18] 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. --- lib/tbot/cli/cli.go | 140 +++++--- lib/tbot/cli/db.go | 68 ++++ lib/tbot/cli/globals.go | 113 +++++++ lib/tbot/cli/init.go | 53 +++ lib/tbot/cli/kube_credentials.go | 39 +++ lib/tbot/cli/migrate.go | 44 +++ lib/tbot/cli/proxy.go | 68 ++++ lib/tbot/cli/ssh_multiplexer_proxy.go | 45 +++ lib/tbot/cli/ssh_proxy.go | 83 +++++ .../{application.go => start_application.go} | 0 ..._tunnel.go => start_application_tunnel.go} | 0 .../cli/{database.go => start_database.go} | 0 ...ase_tunnel.go => start_database_tunnel.go} | 0 .../{kubernetes.go => start_kubernetes.go} | 0 lib/tbot/cli/{legacy.go => start_legacy.go} | 13 +- lib/tbot/cli/{shared.go => start_shared.go} | 28 +- ...x509_svid.go => start_spiffe_x509_svid.go} | 0 lib/tbot/config/config.go | 310 ------------------ lib/tbot/config/config_test.go | 91 ++--- tool/tbot/db.go | 15 +- tool/tbot/init.go | 31 +- tool/tbot/init_test.go | 19 +- tool/tbot/kube.go | 8 +- tool/tbot/main.go | 205 ++++-------- tool/tbot/proxy.go | 13 +- tool/tbot/proxy_ssh.go | 32 +- 26 files changed, 807 insertions(+), 611 deletions(-) create mode 100644 lib/tbot/cli/db.go create mode 100644 lib/tbot/cli/globals.go create mode 100644 lib/tbot/cli/init.go create mode 100644 lib/tbot/cli/kube_credentials.go create mode 100644 lib/tbot/cli/migrate.go create mode 100644 lib/tbot/cli/proxy.go create mode 100644 lib/tbot/cli/ssh_multiplexer_proxy.go create mode 100644 lib/tbot/cli/ssh_proxy.go rename lib/tbot/cli/{application.go => start_application.go} (100%) rename lib/tbot/cli/{application_tunnel.go => start_application_tunnel.go} (100%) rename lib/tbot/cli/{database.go => start_database.go} (100%) rename lib/tbot/cli/{database_tunnel.go => start_database_tunnel.go} (100%) rename lib/tbot/cli/{kubernetes.go => start_kubernetes.go} (100%) rename lib/tbot/cli/{legacy.go => start_legacy.go} (96%) rename lib/tbot/cli/{shared.go => start_shared.go} (83%) rename lib/tbot/cli/{spiffe_x509_svid.go => start_spiffe_x509_svid.go} (100%) diff --git a/lib/tbot/cli/cli.go b/lib/tbot/cli/cli.go index 0222f02d04069..680169f14e77e 100644 --- a/lib/tbot/cli/cli.go +++ b/lib/tbot/cli/cli.go @@ -20,6 +20,7 @@ package cli import ( "log/slog" + "strings" "github.com/alecthomas/kingpin/v2" "github.com/gravitational/teleport" @@ -29,33 +30,22 @@ import ( ) const ( - authServerEnvVar = "TELEPORT_AUTH_SERVER" - tokenEnvVar = "TELEPORT_BOT_TOKEN" - proxyServerEnvVar = "TELEPORT_PROXY" + AuthServerEnvVar = "TELEPORT_AUTH_SERVER" + TokenEnvVar = "TELEPORT_BOT_TOKEN" + ProxyServerEnvVar = "TELEPORT_PROXY" ) var log = logutils.NewPackageLogger(teleport.ComponentKey, teleport.ComponentTBot) -type globalArgs struct { - FIPS bool - - // These properties are not applied to the config. - - ConfigPath string - Debug bool +// 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) } -func (g *globalArgs) ApplyConfig(cfg *config.BotConfig) error { - if g.FIPS { - cfg.FIPS = g.FIPS - } - - if g.Debug { - cfg.Debug = g.Debug - } - - return nil -} +// 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 @@ -88,38 +78,90 @@ func (g *genericMutatorHandler) TryRun(cmd string) (match bool, err error) { return true, trace.Wrap(err) } -// 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) -} - // ConfigMutator is an interface that can apply changes to a BotConfig. type ConfigMutator interface { ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error } -// LoadConfigWithMutator builds a config from an optional config file and a CLI +// 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 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. -// `CheckAndSetDefaults()` will be called on the end result. -func LoadConfigWithMutator(filePath string, mutator ConfigMutator) (*config.BotConfig, error) { +// 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 filePath != "" { - cfg, err = config.ReadConfigFromFile(filePath, false) + 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", filePath) + return nil, trace.Wrap(err, "loading bot config from path %s", globals.ConfigPath) } } else { cfg = &config.BotConfig{} } - l := log.With("config_path", filePath) - if err := mutator.ApplyConfig(cfg, l); err != nil { + 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) } @@ -145,3 +187,27 @@ func BaseConfigWithMutator(mutator ConfigMutator) (*config.BotConfig, error) { 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 +} diff --git a/lib/tbot/cli/db.go b/lib/tbot/cli/db.go new file mode 100644 index 0000000000000..368151b2f5bee --- /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 "github.com/alecthomas/kingpin/v2" + +// 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 *kingpin.Application, 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.Warn("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/globals.go b/lib/tbot/cli/globals.go new file mode 100644 index 0000000000000..72d21ee24bf40 --- /dev/null +++ b/lib/tbot/cli/globals.go @@ -0,0 +1,113 @@ +/* + * 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 + } + + // TODO: in previous versions, `insecure` is handled _after_ + // BotConfig.CheckAndSetDefaults(). This flag is checked and setting it here + // *will* cause a behavioral change, so make sure the new behavior is sane. + // (It is unclear why this was done.) + if g.Insecure { + cfg.Insecure = true + } + + return nil +} diff --git a/lib/tbot/cli/init.go b/lib/tbot/cli/init.go new file mode 100644 index 0000000000000..b970d3b192a98 --- /dev/null +++ b/lib/tbot/cli/init.go @@ -0,0 +1,53 @@ +/* + * 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 ( + "github.com/alecthomas/kingpin/v2" +) + +// InitCommand implements a command for `tbot init` +type InitCommand struct { + *genericExecutorHandler[InitCommand] + + DestinationDir string + 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 *kingpin.Application, action func(*InitCommand) error) *InitCommand { + cmd := app.Command("init", "Initialize a certificate destination directory for writes from a separate bot user.") + + c := &InitCommand{} + c.genericExecutorHandler = newGenericExecutorHandler(cmd, c, action) + + cmd.Flag("destination-dir", "Directory to write short-lived machine certificates to.").StringVar(&c.DestinationDir) + 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 +} diff --git a/lib/tbot/cli/kube_credentials.go b/lib/tbot/cli/kube_credentials.go new file mode 100644 index 0000000000000..8b8cd88bac014 --- /dev/null +++ b/lib/tbot/cli/kube_credentials.go @@ -0,0 +1,39 @@ +/* + * 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 "github.com/alecthomas/kingpin/v2" + +type KubeCredentialsCommand struct { + *genericExecutorHandler[KubeCredentialsCommand] + + DestinationDir string +} + +func NewKubeCredentialsCommand(parentCmd *kingpin.CmdClause, action func(*KubeCredentialsCommand) error) *KubeCredentialsCommand { + cmd := parentCmd.Command("credentials", "Get credentials for kubectl access").Hidden() + + c := &KubeCredentialsCommand{} + c.genericExecutorHandler = newGenericExecutorHandler(cmd, c, action) + + // TODO: this does not appear to be used. remove it? + 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/migrate.go b/lib/tbot/cli/migrate.go new file mode 100644 index 0000000000000..72c40879866a2 --- /dev/null +++ b/lib/tbot/cli/migrate.go @@ -0,0 +1,44 @@ +/* + * 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 ( + "github.com/alecthomas/kingpin/v2" +) + +// 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 *kingpin.Application, 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/proxy.go b/lib/tbot/cli/proxy.go new file mode 100644 index 0000000000000..0463c1a1df91d --- /dev/null +++ b/lib/tbot/cli/proxy.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 "github.com/alecthomas/kingpin/v2" + +// 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 *kingpin.Application, 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.Warn("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/ssh_multiplexer_proxy.go b/lib/tbot/cli/ssh_multiplexer_proxy.go new file mode 100644 index 0000000000000..360e00f417c83 --- /dev/null +++ b/lib/tbot/cli/ssh_multiplexer_proxy.go @@ -0,0 +1,45 @@ +/* + * 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 "github.com/alecthomas/kingpin/v2" + +// SSHMultiplerProxyCommand includes fields for `tbot ssh-multiplexer-proxy-command` +type SSHMultiplerProxyCommand struct { + *genericExecutorHandler[SSHMultiplerProxyCommand] + + Socket string + Data string +} + +// NewSSHMultiplexerProxyCommand initializes and parses args for `tbot ssh-multiplexer-proxy-command` +func NewSSHMultiplexerProxyCommand(app *kingpin.Application, action func(*SSHMultiplerProxyCommand) error) *SSHMultiplerProxyCommand { + 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 := &SSHMultiplerProxyCommand{} + 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_proxy.go b/lib/tbot/cli/ssh_proxy.go new file mode 100644 index 0000000000000..7139653db4ee1 --- /dev/null +++ b/lib/tbot/cli/ssh_proxy.go @@ -0,0 +1,83 @@ +/* + * 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 "github.com/alecthomas/kingpin/v2" + +// 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 *kingpin.Application, 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/application.go b/lib/tbot/cli/start_application.go similarity index 100% rename from lib/tbot/cli/application.go rename to lib/tbot/cli/start_application.go diff --git a/lib/tbot/cli/application_tunnel.go b/lib/tbot/cli/start_application_tunnel.go similarity index 100% rename from lib/tbot/cli/application_tunnel.go rename to lib/tbot/cli/start_application_tunnel.go diff --git a/lib/tbot/cli/database.go b/lib/tbot/cli/start_database.go similarity index 100% rename from lib/tbot/cli/database.go rename to lib/tbot/cli/start_database.go diff --git a/lib/tbot/cli/database_tunnel.go b/lib/tbot/cli/start_database_tunnel.go similarity index 100% rename from lib/tbot/cli/database_tunnel.go rename to lib/tbot/cli/start_database_tunnel.go diff --git a/lib/tbot/cli/kubernetes.go b/lib/tbot/cli/start_kubernetes.go similarity index 100% rename from lib/tbot/cli/kubernetes.go rename to lib/tbot/cli/start_kubernetes.go diff --git a/lib/tbot/cli/legacy.go b/lib/tbot/cli/start_legacy.go similarity index 96% rename from lib/tbot/cli/legacy.go rename to lib/tbot/cli/start_legacy.go index 71e522793a67a..961f82960b815 100644 --- a/lib/tbot/cli/legacy.go +++ b/lib/tbot/cli/start_legacy.go @@ -109,11 +109,11 @@ func NewLegacyCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *Comma action: action, cmd: parentCmd.Command("legacy", "Start with either a config file or a legacy output").Default(), } - c.cmd.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(authServerEnvVar).StringVar(&c.AuthServer) + c.cmd.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(AuthServerEnvVar).StringVar(&c.AuthServer) c.cmd.Flag("data-dir", "Directory to store internal bot data. Access to this directory should be limited.").StringVar(&c.DataDir) c.cmd.Flag("destination-dir", "Directory to write short-lived machine certificates.").StringVar(&c.DestinationDir) - c.cmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(proxyServerEnvVar).StringVar(&c.ProxyServer) - 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("proxy-server", "Address of the Teleport Proxy Server.").Envar(ProxyServerEnvVar).StringVar(&c.ProxyServer) + 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) @@ -145,10 +145,6 @@ func (c *CommandStartLegacy) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) // - FIPS // - Insecure - // if c.Debug { - // cfg.Debug = true - // } - if c.Oneshot { cfg.Oneshot = true } @@ -273,6 +269,3 @@ func (c *CommandStartLegacy) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) return nil } - -// MutatorAction is an action that is called by a config mutator-style command. -type MutatorAction func(mutator ConfigMutator) error diff --git a/lib/tbot/cli/shared.go b/lib/tbot/cli/start_shared.go similarity index 83% rename from lib/tbot/cli/shared.go rename to lib/tbot/cli/start_shared.go index 26038fcdb2feb..6a0464fec6170 100644 --- a/lib/tbot/cli/shared.go +++ b/lib/tbot/cli/start_shared.go @@ -29,7 +29,6 @@ import ( "github.com/alecthomas/kingpin/v2" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/tbot/config" - "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/trace" ) @@ -38,16 +37,14 @@ import ( type sharedStartArgs struct { ProxyServer string JoinMethod string - Insecure bool Token string CAPins []string CertificateTTL time.Duration RenewalInterval time.Duration Storage string - LogFormat string - Oneshot bool - DiagAddr string + Oneshot bool + DiagAddr string } // newSharedStartArgs initializes shared arguments on the given parent command. @@ -59,41 +56,26 @@ func newSharedStartArgs(cmd *kingpin.CmdClause) *sharedStartArgs { strings.Join(config.SupportedJoinMethods, ", "), ) - cmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(proxyServerEnvVar).StringVar(&args.ProxyServer) - 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("proxy-server", "Address of the Teleport Proxy Server.").Envar(ProxyServerEnvVar).StringVar(&args.ProxyServer) + 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("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(&args.Insecure) 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("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`."). - Default(utils.LogFormatText). - EnumVar(&args.LogFormat, utils.LogFormatJSON, utils.LogFormatText) 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 { - // TODO: Weird flags that need to be addressed: - // - Debug - // - FIPS - // - Insecure + // Note: Debug, FIPS, and Insecure are included from globals. if s.Oneshot { cfg.Oneshot = true } - // TODO: in previous versions, `insecure` is handled _after_ - // BotConfig.CheckAndSetDefaults(). This flag is checked and setting it here - // *will* cause a behavioral change, so make sure the new behavior is sane. - // (It is unclear why this was done.) - if s.Insecure { - cfg.Insecure = true - } - if s.ProxyServer != "" { if cfg.ProxyServer != "" { l.WarnContext( diff --git a/lib/tbot/cli/spiffe_x509_svid.go b/lib/tbot/cli/start_spiffe_x509_svid.go similarity index 100% rename from lib/tbot/cli/spiffe_x509_svid.go rename to lib/tbot/cli/start_spiffe_x509_svid.go diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go index c7fbb59e51444..2d049abcfe623 100644 --- a/lib/tbot/config/config.go +++ b/lib/tbot/config/config.go @@ -23,12 +23,10 @@ import ( "fmt" "io" "net/url" - "reflect" "slices" "strings" "time" - "github.com/alecthomas/kingpin/v2" "github.com/gravitational/trace" "go.opentelemetry.io/otel" "gopkg.in/yaml.v3" @@ -44,10 +42,6 @@ import ( const ( DefaultCertificateTTL = 60 * time.Minute DefaultRenewInterval = 20 * time.Minute - - authServerEnvVar = "TELEPORT_AUTH_SERVER" - tokenEnvVar = "TELEPORT_BOT_TOKEN" - proxyServerEnvVar = "TELEPORT_PROXY" ) var tracer = otel.Tracer("github.com/gravitational/teleport/lib/tbot/config") @@ -68,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 @@ -680,161 +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. -// TODO: Remove this function. -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 configuration", - "config_path", cf.ConfigPath, - "flag", "auth-server", - "config_value", config.AuthServer, - "cli_value", cf.AuthServer, - ) - } - config.AuthServer = cf.AuthServer - } - - if cf.ProxyServer != "" { - if config.ProxyServer != "" { - log.WarnContext( - context.TODO(), - "CLI parameters are overriding configuration", - "config_path", cf.ConfigPath, - "flag", "proxy-server", - "config_value", config.ProxyServer, - "cli_value", cf.ProxyServer, - ) - } - config.ProxyServer = cf.ProxyServer - } - - if cf.CertificateTTL != 0 { - if config.CertificateTTL != 0 { - log.WarnContext( - context.TODO(), - "CLI parameters are overriding configuration", - "config_path", cf.ConfigPath, - "flag", "certificate-ttl", - "config_value", config.CertificateTTL, - "cli_value", cf.CertificateTTL, - ) - } - config.CertificateTTL = cf.CertificateTTL - } - - if cf.RenewalInterval != 0 { - if config.RenewalInterval != 0 { - log.WarnContext( - context.TODO(), - "CLI parameters are overriding configuration", - "config_path", cf.ConfigPath, - "flag", "renewal-interval", - "config_value", config.RenewalInterval, - "cli_value", cf.RenewalInterval, - ) - } - 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 configuration", - "config_path", cf.ConfigPath, - "flag", "data-dir", - "config_value", config.Storage.Destination.String(), - "cli_value", cf.DataDir, - ) - } - - dest, err := DestinationFromURI(cf.DataDir) - if err != nil { - return nil, trace.Wrap(err) - } - config.Storage = &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 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 join configuration", - "config_path", cf.ConfigPath, - "cli_token", cf.Token, - "cli_join_method", cf.JoinMethod, - "cli_ca_pins_count", len(cf.CAPins), - ) - } - - 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 configuration", - "config_path", cf.ConfigPath, - "flag", "diag-addr", - "config_value", config.DiagAddr, - "cli_value", cf.DiagAddr, - ) - } - 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_test.go b/lib/tbot/config/config_test.go index 2aa2515498d76..17cf814e2bff4 100644 --- a/lib/tbot/config/config_test.go +++ b/lib/tbot/config/config_test.go @@ -30,55 +30,58 @@ 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) -} +// TODO move this test to /cli/ to prevent an import cycle +// 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 := cli.LoadConfigWithMutators("") + +// //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") diff --git a/tool/tbot/db.go b/tool/tbot/db.go index 7e3f4c08c062a..e2fe32375a31a 100644 --- a/tool/tbot/db.go +++ b/tool/tbot/db.go @@ -23,13 +23,14 @@ 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(cf *config.CLIConf) error { - botConfig, err := config.FromCLIConf(cf) +func onDBCommand(globalCfg *cli.GlobalArgs, dbCmd *cli.DBCommand) error { + botConfig, err := cli.LoadConfigWithMutators(globalCfg) if err != nil { return trace.Wrap(err) } @@ -55,16 +56,16 @@ func onDBCommand(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 369c376b3dae1..af9ee4b25986f 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,8 +421,8 @@ func getAndTestACLOptions(cf *config.CLIConf, destDir string) (*user.User, *user return ownerUser, ownerGroup, &opts, nil } -func onInit(cf *config.CLIConf) error { - botConfig, err := config.FromCLIConf(cf) +func onInit(globals *cli.GlobalArgs, init *cli.InitCommand) error { + botConfig, err := cli.LoadConfigWithMutators(globals) if err != nil { return trace.Wrap(err) } @@ -434,7 +435,7 @@ func onInit(cf *config.CLIConf) error { // 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 { @@ -444,13 +445,13 @@ func onInit(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) } } @@ -478,7 +479,7 @@ func onInit(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. @@ -491,7 +492,7 @@ func onInit(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) } @@ -505,7 +506,7 @@ func onInit(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) } @@ -538,7 +539,7 @@ func onInit(cf *config.CLIConf) error { } // ... and warn about / remove any unneeded files. - if len(toRemove) > 0 && cf.Clean { + if len(toRemove) > 0 && init.Clean { log.InfoContext(ctx, "Attempting to remove", "path", toRemove) var errors []error diff --git a/tool/tbot/init_test.go b/tool/tbot/init_test.go index 05ee64bc6d804..e0d5727cdec05 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" ) @@ -113,11 +115,24 @@ func testConfigFromCLI(t *testing.T, cf *config.CLIConf) *config.BotConfig { } // 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 } diff --git a/tool/tbot/kube.go b/tool/tbot/kube.go index f6fdff00cb580..895ecd6ccaf07 100644 --- a/tool/tbot/kube.go +++ b/tool/tbot/kube.go @@ -33,6 +33,7 @@ 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 +66,11 @@ func getCredentialData(idFile *identityfile.IdentityFile, currentTime time.Time) return data, nil } -func onKubeCredentialsCommand(ctx context.Context, cf *config.CLIConf) error { - cfg, err := config.FromCLIConf(cf) +func onKubeCredentialsCommand( + ctx context.Context, globalCfg *cli.GlobalArgs, + kubeCredentialsCmd *cli.KubeCredentialsCommand, +) error { + cfg, err := cli.LoadConfigWithMutators(globalCfg) if err != nil { return trace.Wrap(err) } diff --git a/tool/tbot/main.go b/tool/tbot/main.go index a330b09888dfe..1f0212f2df3d3 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -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,17 +60,14 @@ 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, 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) @@ -84,6 +75,8 @@ func Run(args []string, stdout io.Writer) error { versionCmd := app.Command("version", "Print the version of your tbot binary.") + kubeCmd := app.Command("kube", "Kubernetes helpers").Hidden() + startCmd := app.Command("start", "Starts the renewal bot, writing certificates to the data dir at a set interval.") configureCmd := app.Command("configure", "Creates a config file based on flags provided, and writes it to stdout or a file (-c ).") configureCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&configureOutPath) @@ -94,96 +87,61 @@ func Run(args []string, stdout io.Writer) error { var commands []cli.CommandRunner commands = append(commands, - cli.NewLegacyCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), - cli.NewLegacyCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), + cli.NewInitCommand(app, func(init *cli.InitCommand) error { + return onInit(globalCfg, init) + }), - cli.NewIdentityCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), - cli.NewIdentityCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), + cli.NewMigrateCommand(app, func(migrateCfg *cli.MigrateCommand) error { + return onMigrate(ctx, globalCfg, migrateCfg, stdout) + }), - cli.NewDatabaseCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), - cli.NewDatabaseCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), + cli.NewSSHProxyCommand(app, func(sshProxyCommand *cli.SSHProxyCommand) error { + return onSSHProxyCommand(ctx, globalCfg, sshProxyCommand) + }), - cli.NewKubernetesCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), - cli.NewKubernetesCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), + cli.NewProxyCommand(app, func(proxyCmd *cli.ProxyCommand) error { + return onProxyCommand(ctx, globalCfg, proxyCmd) + }), - cli.NewApplicationCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), - cli.NewApplicationCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), + cli.NewDBCommand(app, func(dbCmd *cli.DBCommand) error { + return onDBCommand(globalCfg, dbCmd) + }), - cli.NewApplicationTunnelCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), - cli.NewApplicationTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), + cli.NewSSHMultiplexerProxyCommand(app, func(c *cli.SSHMultiplerProxyCommand) error { + return onSSHMultiplexProxyCommand(ctx, c.Socket, c.Data) + }), - cli.NewDatabaseTunnelCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), - cli.NewDatabaseTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), + cli.NewKubeCredentialsCommand(kubeCmd, func(kubeCredentialsCmd *cli.KubeCredentialsCommand) error { + return onKubeCredentialsCommand(ctx, globalCfg, kubeCredentialsCmd) + }), - cli.NewSPIFFEX509SVIDCommand(startCmd, buildConfigAndStart(ctx, cf.ConfigPath)), - cli.NewSPIFFEX509SVIDCommand(configureCmd, buildConfigAndConfigure(ctx, cf.ConfigPath, configureOutPath, stdout)), - ) + // `start` and `configure` commands + cli.NewLegacyCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), + cli.NewLegacyCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), - // TODO: workload id / spiffe service subcommand? + cli.NewIdentityCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), + cli.NewIdentityCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), - 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) - - migrateCmd := app.Command("migrate", "Migrates a config file from an older version to the newest version. Outputs to stdout by default.") - migrateCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&cf.ConfigureOutput) - - legacyProxyFlag := "" - - dbCmd := app.Command("db", "Execute database commands through tsh.") - dbCmd.Flag("proxy-server", "The Teleport proxy server to use, in host:port form.").StringVar(&cf.ProxyServer) - // We're migrating from --proxy to --proxy-server so this flag is hidden - // but still supported. - // TODO(strideynet): DELETE IN 17.0.0 - dbCmd.Flag("proxy", "The Teleport proxy server to use, in host:port form.").Hidden().Envar(proxyServerEnvVar).StringVar(&legacyProxyFlag) - dbCmd.Flag("destination-dir", "The destination directory with which to authenticate tsh").StringVar(&cf.DestinationDir) - dbCmd.Flag("cluster", "The cluster name. Extracted from the certificate if unset.").StringVar(&cf.Cluster) - dbRemaining := config.RemainingArgs(dbCmd.Arg( - "args", - "Arguments to `tsh db ...`; prefix with `-- ` to ensure flags are passed correctly.", - )) - - proxyCmd := app.Command("proxy", "Start a local TLS proxy via tsh to connect to Teleport in single-port mode.") - proxyCmd.Flag("proxy-server", "The Teleport proxy server to use, in host:port form.").Envar(proxyServerEnvVar).StringVar(&cf.ProxyServer) - // We're migrating from --proxy to --proxy-server so this flag is hidden - // but still supported. - // TODO(strideynet): DELETE IN 17.0.0 - proxyCmd.Flag("proxy", "The Teleport proxy server to use, in host:port form.").Hidden().StringVar(&legacyProxyFlag) - proxyCmd.Flag("destination-dir", "The destination directory with which to authenticate tsh").StringVar(&cf.DestinationDir) - proxyCmd.Flag("cluster", "The cluster name. Extracted from the certificate if unset.").StringVar(&cf.Cluster) - proxyRemaining := config.RemainingArgs(proxyCmd.Arg( - "args", - "Arguments to `tsh proxy ...`; prefix with `-- ` to ensure flags are passed correctly.", - )) - - sshProxyCmd := app.Command("ssh-proxy-command", "An OpenSSH/PuTTY proxy command").Hidden() - sshProxyCmd.Flag("destination-dir", "The destination directory with which to authenticate tsh").StringVar(&cf.DestinationDir) - sshProxyCmd.Flag("cluster", "The cluster name. Extracted from the certificate if unset.").StringVar(&cf.Cluster) - sshProxyCmd.Flag("user", "The remote user name for the connection").Required().StringVar(&cf.User) - sshProxyCmd.Flag("host", "The remote host to connect to").Required().StringVar(&cf.Host) - sshProxyCmd.Flag("port", "The remote port to connect on.").StringVar(&cf.Port) - sshProxyCmd.Flag("proxy-server", "The Teleport proxy server to use, in host:port form.").Required().StringVar(&cf.ProxyServer) - sshProxyCmd.Flag("tls-routing", "Whether the Teleport cluster has tls routing enabled.").Required().BoolVar(&cf.TLSRoutingEnabled) - sshProxyCmd.Flag("connection-upgrade", "Whether the Teleport cluster requires an ALPN connection upgrade.").Required().BoolVar(&cf.ConnectionUpgradeRequired) - sshProxyCmd.Flag("proxy-templates", "The path to a file containing proxy templates to be evaluated.").StringVar(&cf.TSHConfigPath) - sshProxyCmd.Flag("resume", "Enable SSH connection resumption").BoolVar(&cf.EnableResumption) - - sshMultiplexProxyCmd := app.Command("ssh-multiplexer-proxy-command", "An OpenSSH compatible ProxyCommand which connects to a long-lived tbot running the ssh-multiplexer service").Hidden() - var sshMultiplexSocket string - var sshMultiplexData string - sshMultiplexProxyCmd.Arg("path", "Path to the listener socket.").Required().StringVar(&sshMultiplexSocket) - sshMultiplexProxyCmd.Arg("data", "Connection target.").Required().StringVar(&sshMultiplexData) + cli.NewDatabaseCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), + cli.NewDatabaseCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), - 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.NewKubernetesCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), + cli.NewKubernetesCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + + cli.NewApplicationCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), + cli.NewApplicationCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + + cli.NewApplicationTunnelCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), + cli.NewApplicationTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + + cli.NewDatabaseTunnelCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), + cli.NewDatabaseTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + + cli.NewSPIFFEX509SVIDCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), + cli.NewSPIFFEX509SVIDCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + ) + + // TODO: workload id / spiffe service subcommand? spiffeInspectPath := "" spiffeInspectCmd := app.Command("spiffe-inspect", "Inspects a SPIFFE Workload API endpoint to ensure it is working correctly.") @@ -202,34 +160,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") } @@ -292,11 +233,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(): @@ -306,22 +244,10 @@ 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 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) - case initCmd.FullCommand(): - return onInit(&cf) - case dbCmd.FullCommand(): - return onDBCommand(&cf) - case proxyCmd.FullCommand(): - return onProxyCommand(ctx, &cf) - case kubeCredentialsCmd.FullCommand(): - return onKubeCredentialsCommand(ctx, &cf) + return installSystemdCmdFn(ctx, log, globalCfg.ConfigPath, os.Executable, os.Stdout) } // Attempt to run each new-style command. @@ -339,9 +265,9 @@ func Run(args []string, stdout io.Writer) error { // buildConfigAndStart returns a MutatorAction that will generate a config and // run `onStart` with the result. -func buildConfigAndStart(ctx context.Context, configPath string) cli.MutatorAction { +func buildConfigAndStart(ctx context.Context, globals *cli.GlobalArgs) cli.MutatorAction { return func(mutator cli.ConfigMutator) error { - cfg, err := cli.LoadConfigWithMutator(configPath, mutator) + cfg, err := cli.LoadConfigWithMutators(globals, mutator) if err != nil { return trace.Wrap(err) } @@ -352,7 +278,7 @@ func buildConfigAndStart(ctx context.Context, configPath string) cli.MutatorActi // buildConfigAndConfigure returns a MutatorAction that will generate a config // and run `onConfigure` with the result. -func buildConfigAndConfigure(ctx context.Context, configPath string, outPath string, stdout io.Writer) cli.MutatorAction { +func buildConfigAndConfigure(ctx context.Context, outPath string, stdout io.Writer) cli.MutatorAction { return func(mutator cli.ConfigMutator) error { cfg, err := cli.BaseConfigWithMutator(mutator) if err != nil { @@ -434,17 +360,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") } @@ -458,7 +385,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 d16c4bebfc65d..677a0caeb93a0 100644 --- a/tool/tbot/proxy.go +++ b/tool/tbot/proxy.go @@ -26,14 +26,15 @@ 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, cf *config.CLIConf, + ctx context.Context, globalCfg *cli.GlobalArgs, proxyCmd *cli.ProxyCommand, ) error { - botConfig, err := config.FromCLIConf(cf) + botConfig, err := cli.LoadConfigWithMutators(globalCfg) if err != nil { return trace.Wrap(err) } @@ -57,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`). @@ -68,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( @@ -80,7 +81,7 @@ func onProxyCommand( destination.Path, "kubeconfig-proxied.yaml", ) } - if slices.Contains(cf.RemainingArgs, "ssh") { + if slices.Contains(*proxyCmd.ProxyRemaining, "ssh") { log.WarnContext(ctx, "`tbot proxy ssh` is deprecated and will stop working in v17. See https://goteleport.com/docs/machine-id/reference/v16-upgrade-guide/") } diff --git a/tool/tbot/proxy_ssh.go b/tool/tbot/proxy_ssh.go index 52928f3b8ac8c..f4bc17a0c7a09 100644 --- a/tool/tbot/proxy_ssh.go +++ b/tool/tbot/proxy_ssh.go @@ -23,7 +23,7 @@ import ( "github.com/gravitational/trace" "github.com/gravitational/teleport/lib/tbot" - "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/tbot/cli" ) // onSSHProxyCommand is meant to be used as an OpenSSH/PuTTY proxy command. While this @@ -31,27 +31,27 @@ import ( // `tsh proxy ssh` which results in much less memory and cpu consumption. This will // eventually supersede `tbot proxy ssh` as it becomes more feature rich and supports // all the edge cases. -func onSSHProxyCommand(ctx context.Context, cf *config.CLIConf) error { +func onSSHProxyCommand(ctx context.Context, globalCfg *cli.GlobalArgs, sshProxyCmd *cli.SSHProxyCommand) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - if cf.Port == "" { - cf.Port = "0" + if sshProxyCmd.Port == "" { + sshProxyCmd.Port = "0" } proxySSHConfig := tbot.ProxySSHConfig{ - DestinationPath: cf.DestinationDir, - Insecure: cf.Insecure, - FIPS: cf.FIPS, - ProxyServer: cf.ProxyServer, - Cluster: cf.Cluster, - User: cf.User, - Host: cf.Host, - Port: cf.Port, - EnableResumption: cf.EnableResumption, - TLSRoutingEnabled: cf.TLSRoutingEnabled, - ConnectionUpgradeRequired: cf.ConnectionUpgradeRequired, - TSHConfigPath: cf.TSHConfigPath, + Insecure: globalCfg.Insecure, + FIPS: globalCfg.FIPS, + DestinationPath: sshProxyCmd.DestinationDir, + ProxyServer: sshProxyCmd.ProxyServer, + Cluster: sshProxyCmd.Cluster, + User: sshProxyCmd.User, + Host: sshProxyCmd.Host, + Port: sshProxyCmd.Port, + EnableResumption: sshProxyCmd.EnableResumption, + TLSRoutingEnabled: sshProxyCmd.TLSRoutingEnabled, + ConnectionUpgradeRequired: sshProxyCmd.ConnectionUpgradeRequired, + TSHConfigPath: sshProxyCmd.TSHConfigPath, Log: log, } From 143a8198a070f26e65748424ca6ed377a6142a3b Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Mon, 7 Oct 2024 21:22:50 -0600 Subject: [PATCH 04/18] 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. --- lib/tbot/cli/cli.go | 20 +- lib/tbot/cli/cli_test.go | 316 ++++++++++++++++++ lib/tbot/cli/db.go | 4 +- lib/tbot/cli/globals.go | 1 + lib/tbot/cli/init.go | 40 ++- lib/tbot/cli/kube_credentials.go | 4 +- lib/tbot/cli/migrate.go | 6 +- lib/tbot/cli/ssh_multiplexer_proxy.go | 4 +- lib/tbot/cli/start_application.go | 5 +- lib/tbot/cli/start_application_tunnel.go | 3 +- lib/tbot/cli/start_database.go | 3 +- lib/tbot/cli/start_database_tunnel.go | 5 +- .../cli/{identity.go => start_identity.go} | 13 +- lib/tbot/cli/start_kubernetes.go | 3 +- lib/tbot/cli/start_legacy.go | 143 ++++---- lib/tbot/cli/start_shared.go | 88 ++++- lib/tbot/cli/start_spiffe_x509_svid.go | 3 +- tool/tbot/init.go | 3 +- tool/tbot/init_test.go | 60 ++-- tool/tbot/main.go | 29 +- 20 files changed, 585 insertions(+), 168 deletions(-) create mode 100644 lib/tbot/cli/cli_test.go rename lib/tbot/cli/{identity.go => start_identity.go} (88%) diff --git a/lib/tbot/cli/cli.go b/lib/tbot/cli/cli.go index 680169f14e77e..3159ac9a9b2ef 100644 --- a/lib/tbot/cli/cli.go +++ b/lib/tbot/cli/cli.go @@ -23,10 +23,11 @@ import ( "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" - "github.com/gravitational/trace" ) const ( @@ -126,6 +127,10 @@ func (e *genericExecutorHandler[T]) TryRun(cmd string) (match bool, err error) { 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) } @@ -172,12 +177,12 @@ func LoadConfigWithMutators(globals *GlobalArgs, mutators ...ConfigMutator) (*co return cfg, nil } -// BaseConfigWithMutator returns a base bot config with a given CLI mutator +// 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 BaseConfigWithMutator(mutator ConfigMutator) (*config.BotConfig, error) { +func BaseConfigWithMutators(mutators ...ConfigMutator) (*config.BotConfig, error) { cfg := &config.BotConfig{} - if err := mutator.ApplyConfig(cfg, log); err != nil { + if err := applyMutators(log, cfg, mutators...); err != nil { return nil, trace.Wrap(err) } @@ -211,3 +216,10 @@ func RemainingArgs(s kingpin.Settings) (target *[]string) { s.SetValue((*RemainingArgsList)(target)) return } + +// KingpinClause allows commands and flags to mount to either the root app +// (kingpin.Application) or a subcommand (kingpin.CmdClause) +type KingpinClause interface { + Command(name string, help string) *kingpin.CmdClause + Flag(name string, help string) *kingpin.FlagClause +} diff --git a/lib/tbot/cli/cli_test.go b/lib/tbot/cli/cli_test.go new file mode 100644 index 0000000000000..c87ba7db5e040 --- /dev/null +++ b/lib/tbot/cli/cli_test.go @@ -0,0 +1,316 @@ +/* + * 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 ( + "fmt" + "testing" + + "github.com/alecthomas/kingpin/v2" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/utils" +) + +type configMutatorMock struct { + mock.Mock +} + +func (m *configMutatorMock) action(mut ConfigMutator) error { + args := m.Called(mut) + return args.Error(0) +} + +type genericExecutorMock[T any] struct { + mock.Mock +} + +func (m *genericExecutorMock[T]) action(cmd *T) error { + args := m.Called(cmd) + return args.Error(0) +} + +func buildMinimalKingpinApp(subcommandName string) (app *kingpin.Application, subcommand *kingpin.CmdClause) { + app = utils.InitCLIParser("tbot", "test").Interspersed(false) + subcommand = app.Command(subcommandName, "subcommand") + + return +} + +// TestConfigMutators that all config mutator-style match on their expected +// CLI args and return appropriately-typed parse results. This does not validate +// that the resulting configuration is valid (i.e. may not successfully pass +// conversion to BotConfig and the associated CheckAndSetDefaults()) +func TestConfigMutators(t *testing.T) { + tests := []struct { + name string + args [][]string + buildCommand func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner + assert func(t *testing.T, value any) + }{ + { + name: "legacy", + args: [][]string{{}, {"legacy"}}, + buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { + return NewLegacyCommand(parent, callback) + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &LegacyCommand{}, value) + }, + }, + { + name: "identity", + args: [][]string{ + {"identity", "--destination=foo"}, + {"id", "--destination=foo"}, + {"ssh", "--destination=foo"}, + }, + buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { + return NewIdentityCommand(parent, callback) + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &IdentityCommand{}, value) + }, + }, + + { + name: "database", + args: [][]string{ + {"database", "--destination=foo", "--service=foo", "--username=bar", "--database=baz"}, + {"db", "--destination=foo", "--service=foo", "--username=bar", "--database=baz"}, + }, + buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { + return NewDatabaseCommand(parent, callback) + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &DatabaseCommand{}, value) + }, + }, + { + name: "kubernetes", + args: [][]string{ + {"kubernetes", "--destination=foo", "--kubernetes-cluster=foo"}, + {"k8s", "--destination=foo", "--kubernetes-cluster=foo"}, + }, + buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { + return NewKubernetesCommand(parent, callback) + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &KubernetesCommand{}, value) + }, + }, + { + name: "application", + args: [][]string{ + {"application", "--destination=foo", "--app=foo"}, + {"app", "--destination=foo", "--app=foo"}, + }, + buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { + return NewApplicationCommand(parent, callback) + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &ApplicationCommand{}, value) + }, + }, + { + name: "spiffe-x509-svid", + args: [][]string{ + {"spiffe-x509-svid", "--destination=foo", "--svid-path=/bar"}, + }, + buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { + return NewSPIFFEX509SVIDCommand(parent, callback) + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &SPIFFEX509SVIDCommand{}, value) + }, + }, + { + name: "application-tunnel", + args: [][]string{ + {"application-tunnel", "--app=foo", "--listen=tcp://0.0.0.0:8080"}, + {"app-tunnel", "--app=foo", "--listen=tcp://0.0.0.0:8080"}, + }, + buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { + return NewApplicationTunnelCommand(parent, callback) + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &ApplicationTunnelCommand{}, value) + }, + }, + { + name: "database-tunnel", + args: [][]string{ + {"database-tunnel", "--service=foo", "--username=bar", "--database=baz", "--listen=tcp://0.0.0.0:8080"}, + {"db-tunnel", "--service=foo", "--username=bar", "--database=baz", "--listen=tcp://0.0.0.0:8080"}, + }, + buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { + return NewDatabaseTunnelCommand(parent, callback) + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &DatabaseTunnelCommand{}, value) + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + for i, argSet := range tt.args { + argSet := argSet + + t.Run(fmt.Sprint(i), func(t *testing.T) { + subcommandName := "sub" + app, subcommand := buildMinimalKingpinApp(subcommandName) + + mockAction := configMutatorMock{} + mockAction.On("action", mock.Anything).Return(nil) + + runner := tt.buildCommand(subcommand, mockAction.action) + + command, err := app.Parse(append([]string{subcommandName}, argSet...)) + require.NoError(t, err) + + match, err := runner.TryRun(command) + require.NoError(t, err) + require.True(t, match) + + mockAction.AssertCalled(t, "action", mock.Anything) + + arg := mockAction.Calls[0].Arguments.Get(0) + tt.assert(t, arg) + }) + } + }) + } +} + +func TestExecutors(t *testing.T) { + // Note: Currently all executor-style + tests := []struct { + name string + args []string + buildCommand func(app *kingpin.Application) (CommandRunner, *mock.Mock) + assert func(t *testing.T, value any) + }{ + { + name: "init", + args: []string{"init"}, + buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { + m := &genericExecutorMock[InitCommand]{} + return NewInitCommand(app, m.action), &m.Mock + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &InitCommand{}, value) + }, + }, + { + name: "db", + args: []string{"db"}, + buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { + m := &genericExecutorMock[DBCommand]{} + return NewDBCommand(app, m.action), &m.Mock + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &DBCommand{}, value) + }, + }, + { + // note: this expects to be mounted to a "kube" parent command. for + // the test, we'll just mount it to the application. + name: "kube credentials", + args: []string{"credentials", "--destination-dir=foo"}, + buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { + m := &genericExecutorMock[KubeCredentialsCommand]{} + return NewKubeCredentialsCommand(app, m.action), &m.Mock + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &KubeCredentialsCommand{}, value) + }, + }, + { + name: "migrate", + args: []string{"migrate"}, + buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { + m := &genericExecutorMock[MigrateCommand]{} + return NewMigrateCommand(app, m.action), &m.Mock + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &MigrateCommand{}, value) + }, + }, + { + name: "proxy", + args: []string{"proxy"}, + buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { + m := &genericExecutorMock[ProxyCommand]{} + return NewProxyCommand(app, m.action), &m.Mock + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &ProxyCommand{}, value) + }, + }, + { + name: "ssh-multiplexer-proxy-command", + args: []string{"ssh-multiplexer-proxy-command", "/foo", "bar"}, + buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { + m := &genericExecutorMock[SSHMultiplerProxyCommand]{} + return NewSSHMultiplexerProxyCommand(app, m.action), &m.Mock + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &SSHMultiplerProxyCommand{}, value) + }, + }, + { + name: "ssh-proxy-command", + args: []string{"ssh-proxy-command", "--user=foo", "--host=bar", "--proxy-server=baz", "--tls-routing", "--connection-upgrade"}, + buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { + m := &genericExecutorMock[SSHProxyCommand]{} + return NewSSHProxyCommand(app, m.action), &m.Mock + }, + assert: func(t *testing.T, value any) { + require.IsType(t, &SSHProxyCommand{}, value) + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + app, _ := buildMinimalKingpinApp("sub") + + runner, mockAction := tt.buildCommand(app) + mockAction.On("action", mock.Anything).Return(nil) + + command, err := app.Parse(tt.args) + require.NoError(t, err) + + match, err := runner.TryRun(command) + require.NoError(t, err) + require.True(t, match) + + mockAction.AssertCalled(t, "action", mock.Anything) + + arg := mockAction.Calls[0].Arguments.Get(0) + tt.assert(t, arg) + }) + } +} diff --git a/lib/tbot/cli/db.go b/lib/tbot/cli/db.go index 368151b2f5bee..eeb39a237ba65 100644 --- a/lib/tbot/cli/db.go +++ b/lib/tbot/cli/db.go @@ -18,8 +18,6 @@ package cli -import "github.com/alecthomas/kingpin/v2" - // DBCommand contains fields for `tbot db` type DBCommand struct { *genericExecutorHandler[DBCommand] @@ -37,7 +35,7 @@ type DBCommand struct { } // NewDBCommand initializes flags for `tbot db` -func NewDBCommand(app *kingpin.Application, action func(*DBCommand) error) *DBCommand { +func NewDBCommand(app KingpinClause, action func(*DBCommand) error) *DBCommand { cmd := app.Command("db", "Execute database commands through tsh.") c := &DBCommand{} diff --git a/lib/tbot/cli/globals.go b/lib/tbot/cli/globals.go index 72d21ee24bf40..a4d25cdee9db3 100644 --- a/lib/tbot/cli/globals.go +++ b/lib/tbot/cli/globals.go @@ -22,6 +22,7 @@ import ( "log/slog" "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/teleport/lib/tbot/config" "github.com/gravitational/teleport/lib/utils" ) diff --git a/lib/tbot/cli/init.go b/lib/tbot/cli/init.go index b970d3b192a98..6183fd55639fd 100644 --- a/lib/tbot/cli/init.go +++ b/lib/tbot/cli/init.go @@ -19,30 +19,36 @@ package cli import ( - "github.com/alecthomas/kingpin/v2" + "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] - DestinationDir string - Owner string - BotUser string - ReaderUser string - InitDir string - Clean bool + 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 *kingpin.Application, action func(*InitCommand) error) *InitCommand { +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("destination-dir", "Directory to write short-lived machine certificates to.").StringVar(&c.DestinationDir) 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) @@ -51,3 +57,19 @@ func NewInitCommand(app *kingpin.Application, action func(*InitCommand) error) * 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/kube_credentials.go b/lib/tbot/cli/kube_credentials.go index 8b8cd88bac014..88906003daa38 100644 --- a/lib/tbot/cli/kube_credentials.go +++ b/lib/tbot/cli/kube_credentials.go @@ -18,15 +18,13 @@ package cli -import "github.com/alecthomas/kingpin/v2" - type KubeCredentialsCommand struct { *genericExecutorHandler[KubeCredentialsCommand] DestinationDir string } -func NewKubeCredentialsCommand(parentCmd *kingpin.CmdClause, action func(*KubeCredentialsCommand) error) *KubeCredentialsCommand { +func NewKubeCredentialsCommand(parentCmd KingpinClause, action func(*KubeCredentialsCommand) error) *KubeCredentialsCommand { cmd := parentCmd.Command("credentials", "Get credentials for kubectl access").Hidden() c := &KubeCredentialsCommand{} diff --git a/lib/tbot/cli/migrate.go b/lib/tbot/cli/migrate.go index 72c40879866a2..da44590208349 100644 --- a/lib/tbot/cli/migrate.go +++ b/lib/tbot/cli/migrate.go @@ -18,10 +18,6 @@ package cli -import ( - "github.com/alecthomas/kingpin/v2" -) - // MigrateCommand contains fields parsed for `tbot migrate` type MigrateCommand struct { *genericExecutorHandler[MigrateCommand] @@ -32,7 +28,7 @@ type MigrateCommand struct { } // NewMigrateCommand initializes the `tbot migrate` command and its flags. -func NewMigrateCommand(app *kingpin.Application, action func(*MigrateCommand) error) *MigrateCommand { +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{} diff --git a/lib/tbot/cli/ssh_multiplexer_proxy.go b/lib/tbot/cli/ssh_multiplexer_proxy.go index 360e00f417c83..5dec0fc17aa92 100644 --- a/lib/tbot/cli/ssh_multiplexer_proxy.go +++ b/lib/tbot/cli/ssh_multiplexer_proxy.go @@ -18,8 +18,6 @@ package cli -import "github.com/alecthomas/kingpin/v2" - // SSHMultiplerProxyCommand includes fields for `tbot ssh-multiplexer-proxy-command` type SSHMultiplerProxyCommand struct { *genericExecutorHandler[SSHMultiplerProxyCommand] @@ -29,7 +27,7 @@ type SSHMultiplerProxyCommand struct { } // NewSSHMultiplexerProxyCommand initializes and parses args for `tbot ssh-multiplexer-proxy-command` -func NewSSHMultiplexerProxyCommand(app *kingpin.Application, action func(*SSHMultiplerProxyCommand) error) *SSHMultiplerProxyCommand { +func NewSSHMultiplexerProxyCommand(app KingpinClause, action func(*SSHMultiplerProxyCommand) error) *SSHMultiplerProxyCommand { cmd := app.Command( "ssh-multiplexer-proxy-command", "An OpenSSH compatible ProxyCommand which connects to a long-lived tbot running the ssh-multiplexer service", diff --git a/lib/tbot/cli/start_application.go b/lib/tbot/cli/start_application.go index a56e8a734ea01..a29e8c6b2b2aa 100644 --- a/lib/tbot/cli/start_application.go +++ b/lib/tbot/cli/start_application.go @@ -22,8 +22,9 @@ import ( "log/slog" "github.com/alecthomas/kingpin/v2" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/tbot/config" ) // ApplicationCommand implements `tbot start application` and @@ -46,7 +47,7 @@ func NewApplicationCommand(parentCmd *kingpin.CmdClause, action MutatorAction) * 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").Required().BoolVar(&c.SpecificTLSExtensions) + 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. diff --git a/lib/tbot/cli/start_application_tunnel.go b/lib/tbot/cli/start_application_tunnel.go index c78836f834d04..9874aae0f7a6b 100644 --- a/lib/tbot/cli/start_application_tunnel.go +++ b/lib/tbot/cli/start_application_tunnel.go @@ -22,8 +22,9 @@ import ( "log/slog" "github.com/alecthomas/kingpin/v2" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/tbot/config" ) // ApplicationTunnelCommand implements `tbot start application-tunnel` and diff --git a/lib/tbot/cli/start_database.go b/lib/tbot/cli/start_database.go index 7d36557c10d3e..199b5abee89b2 100644 --- a/lib/tbot/cli/start_database.go +++ b/lib/tbot/cli/start_database.go @@ -22,8 +22,9 @@ import ( "log/slog" "github.com/alecthomas/kingpin/v2" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/tbot/config" ) // DatabaseCommand implements `tbot start database` and diff --git a/lib/tbot/cli/start_database_tunnel.go b/lib/tbot/cli/start_database_tunnel.go index fdf12968bf095..be863f6a933f4 100644 --- a/lib/tbot/cli/start_database_tunnel.go +++ b/lib/tbot/cli/start_database_tunnel.go @@ -22,8 +22,9 @@ import ( "log/slog" "github.com/alecthomas/kingpin/v2" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/tbot/config" ) // DatabaseTunnelCommand implements `tbot start database-tunnel` and @@ -40,7 +41,7 @@ type DatabaseTunnelCommand struct { // NewDatabaseTunnelCommand creates a command supporting `tbot start database-tunnel` func NewDatabaseTunnelCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *DatabaseTunnelCommand { - cmd := parentCmd.Command("database", "Start a database tunnel listener").Alias("db-tunnel") + cmd := parentCmd.Command("database-tunnel", "Start a database tunnel listener").Alias("db-tunnel") c := &DatabaseTunnelCommand{} c.sharedStartArgs = newSharedStartArgs(cmd) diff --git a/lib/tbot/cli/identity.go b/lib/tbot/cli/start_identity.go similarity index 88% rename from lib/tbot/cli/identity.go rename to lib/tbot/cli/start_identity.go index b6a62a6e5874f..2e707bf8bc153 100644 --- a/lib/tbot/cli/identity.go +++ b/lib/tbot/cli/start_identity.go @@ -22,13 +22,14 @@ import ( "log/slog" "github.com/alecthomas/kingpin/v2" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/tbot/config" ) -// CommandStartIdentity implements `tbot start identity` and +// IdentityCommand implements `tbot start identity` and // `tbot configure identity`. -type CommandStartIdentity struct { +type IdentityCommand struct { *sharedStartArgs *genericMutatorHandler @@ -36,10 +37,10 @@ type CommandStartIdentity struct { Cluster string } -func NewIdentityCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *CommandStartIdentity { +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 := &CommandStartIdentity{} + c := &IdentityCommand{} c.sharedStartArgs = newSharedStartArgs(cmd) c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action) @@ -51,7 +52,7 @@ func NewIdentityCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *Com return c } -func (c *CommandStartIdentity) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error { +func (c *IdentityCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error { if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil { return trace.Wrap(err) } diff --git a/lib/tbot/cli/start_kubernetes.go b/lib/tbot/cli/start_kubernetes.go index f2d7118d078cd..abd1db41a4753 100644 --- a/lib/tbot/cli/start_kubernetes.go +++ b/lib/tbot/cli/start_kubernetes.go @@ -22,8 +22,9 @@ import ( "log/slog" "github.com/alecthomas/kingpin/v2" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/tbot/config" ) // KubernetesCommand implements `tbot start kubernetes` and diff --git a/lib/tbot/cli/start_legacy.go b/lib/tbot/cli/start_legacy.go index 961f82960b815..33d3e88390fbe 100644 --- a/lib/tbot/cli/start_legacy.go +++ b/lib/tbot/cli/start_legacy.go @@ -27,16 +27,69 @@ import ( "time" "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/tbot/config" - "github.com/gravitational/teleport/lib/utils" - "github.com/gravitational/trace" ) -// CommandStartLegacy starts with legacy behavior. This handles flags somewhat +// 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 CommandStartLegacy struct { +type LegacyCommand struct { + *AuthProxyArgs + *LegacyDestinationDirArgs + // cmd is the concrete command for this instance cmd *kingpin.CmdClause @@ -47,18 +100,9 @@ type CommandStartLegacy struct { // 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 - // Destination is a destination URI Destination string @@ -84,51 +128,38 @@ type CommandStartLegacy struct { // Oneshot controls whether the bot quits after a single renewal. Oneshot bool - // ProxyServer is the teleport proxy address. Unlike `AuthServer` this must - // explicitly point to a Teleport proxy. - // Example: "example.teleport.sh:443" - ProxyServer string - // 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 } // NewLegacyCommand initializes and returns a command supporting // `tbot start legacy` and `tbot configure legacy`. -func NewLegacyCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *CommandStartLegacy { +func NewLegacyCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *LegacyCommand { joinMethodList := fmt.Sprintf( "(%s)", strings.Join(config.SupportedJoinMethods, ", "), ) - c := &CommandStartLegacy{ + c := &LegacyCommand{ action: action, cmd: parentCmd.Command("legacy", "Start with either a config file or a legacy output").Default(), } - c.cmd.Flag("auth-server", "Address of the Teleport Auth Server. Prefer using --proxy-server where possible.").Short('a').Envar(AuthServerEnvVar).StringVar(&c.AuthServer) + c.AuthProxyArgs = newAuthProxyArgs(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("destination-dir", "Directory to write short-lived machine certificates.").StringVar(&c.DestinationDir) - c.cmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(ProxyServerEnvVar).StringVar(&c.ProxyServer) 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("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(&c.Insecure) 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) - c.cmd.Flag("log-format", "Controls the format of output logs. Can be `json` or `text`. Defaults to `text`."). - Default(utils.LogFormatText). - EnumVar(&c.LogFormat, utils.LogFormatJSON, utils.LogFormatText) return c } -func (c *CommandStartLegacy) TryRun(cmd string) (match bool, err error) { +func (c *LegacyCommand) TryRun(cmd string) (match bool, err error) { switch cmd { case c.cmd.FullCommand(): err = c.action(c) @@ -139,40 +170,23 @@ func (c *CommandStartLegacy) TryRun(cmd string) (match bool, err error) { return true, trace.Wrap(err) } -func (c *CommandStartLegacy) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error { - // TODO: Weird flags that need to be addressed: - // - Debug - // - FIPS - // - Insecure +func (c *LegacyCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error { + // Note: Debug, FIPS, and Insecure are included from globals - if c.Oneshot { - cfg.Oneshot = true + if c.AuthProxyArgs != nil { + if err := c.AuthProxyArgs.ApplyConfig(cfg, l); err != nil { + return trace.Wrap(err) + } } - if c.AuthServer != "" { - if cfg.AuthServer != "" { - log.WarnContext( - context.TODO(), - "CLI parameters are overriding configuration", - "flag", "auth-server", - "config_value", cfg.AuthServer, - "cli_value", c.AuthServer, - ) + if c.LegacyDestinationDirArgs != nil { + if err := c.LegacyDestinationDirArgs.ApplyConfig(cfg, l); err != nil { + return trace.Wrap(err) } - cfg.AuthServer = c.AuthServer } - if c.ProxyServer != "" { - if cfg.ProxyServer != "" { - log.WarnContext( - context.TODO(), - "CLI parameters are overriding configuration", - "flag", "proxy-server", - "config_value", cfg.ProxyServer, - "cli_value", c.ProxyServer, - ) - } - cfg.ProxyServer = c.ProxyServer + if c.Oneshot { + cfg.Oneshot = true } if c.CertificateTTL != 0 { @@ -243,11 +257,6 @@ func (c *CommandStartLegacy) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) cfg.Onboarding.SetToken(c.Token) } - // TODO: - // if c.FIPS { - // cfg.FIPS = c.FIPS - // } - if c.DiagAddr != "" { if cfg.DiagAddr != "" { log.WarnContext( @@ -261,11 +270,5 @@ func (c *CommandStartLegacy) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) cfg.DiagAddr = c.DiagAddr } - // TODO: This is now set _before_ CheckAndSetDefaults() which causes a mild - // change in behavior. Verify this is tolerable. - if c.Insecure { - cfg.Insecure = true - } - return nil } diff --git a/lib/tbot/cli/start_shared.go b/lib/tbot/cli/start_shared.go index 6a0464fec6170..1b849f872a351 100644 --- a/lib/tbot/cli/start_shared.go +++ b/lib/tbot/cli/start_shared.go @@ -27,15 +27,79 @@ import ( "time" "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/tbot/config" - "github.com/gravitational/trace" ) +// 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, + } +} + +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 { - ProxyServer string + *AuthProxyArgs + JoinMethod string Token string CAPins []string @@ -56,7 +120,6 @@ func newSharedStartArgs(cmd *kingpin.CmdClause) *sharedStartArgs { strings.Join(config.SupportedJoinMethods, ", "), ) - cmd.Flag("proxy-server", "Address of the Teleport Proxy Server.").Envar(ProxyServerEnvVar).StringVar(&args.ProxyServer) 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) @@ -72,21 +135,14 @@ func newSharedStartArgs(cmd *kingpin.CmdClause) *sharedStartArgs { func (s *sharedStartArgs) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error { // Note: Debug, FIPS, and Insecure are included from globals. - if s.Oneshot { - cfg.Oneshot = true + if s.AuthProxyArgs != nil { + if err := s.AuthProxyArgs.ApplyConfig(cfg, l); err != nil { + return trace.Wrap(err) + } } - if s.ProxyServer != "" { - if cfg.ProxyServer != "" { - l.WarnContext( - context.TODO(), - "CLI parameters are overriding configuration", - "flag", "proxy-server", - "config_value", cfg.ProxyServer, - "cli_value", s.ProxyServer, - ) - } - cfg.ProxyServer = s.ProxyServer + if s.Oneshot { + cfg.Oneshot = true } if s.CertificateTTL != 0 { diff --git a/lib/tbot/cli/start_spiffe_x509_svid.go b/lib/tbot/cli/start_spiffe_x509_svid.go index cd3d8ec5bb46e..9246c971f1cb9 100644 --- a/lib/tbot/cli/start_spiffe_x509_svid.go +++ b/lib/tbot/cli/start_spiffe_x509_svid.go @@ -22,8 +22,9 @@ import ( "log/slog" "github.com/alecthomas/kingpin/v2" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/tbot/config" ) // SPIFFEX509SVIDCommand implements `tbot start spiffe-x509-svid` and diff --git a/tool/tbot/init.go b/tool/tbot/init.go index af9ee4b25986f..06f574d89e262 100644 --- a/tool/tbot/init.go +++ b/tool/tbot/init.go @@ -422,7 +422,7 @@ func getAndTestACLOptions(initCmd *cli.InitCommand, destDir string) (*user.User, } func onInit(globals *cli.GlobalArgs, init *cli.InitCommand) error { - botConfig, err := cli.LoadConfigWithMutators(globals) + botConfig, err := cli.LoadConfigWithMutators(globals, init) if err != nil { return trace.Wrap(err) } @@ -431,6 +431,7 @@ func onInit(globals *cli.GlobalArgs, init *cli.InitCommand) error { defer cancel() initables := botConfig.GetInitables() + log.Warn("initables", "initables", initables) 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, diff --git a/tool/tbot/init_test.go b/tool/tbot/init_test.go index e0d5727cdec05..a9d6e8ef34ad3 100644 --- a/tool/tbot/init_test.go +++ b/tool/tbot/init_test.go @@ -106,14 +106,6 @@ 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, yamlStr string) *config.BotConfig { // Load YAML to validate syntax. @@ -159,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]) @@ -200,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]) @@ -253,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]) } @@ -273,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/main.go b/tool/tbot/main.go index 1f0212f2df3d3..02d745de2af04 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -73,18 +73,19 @@ func Run(args []string, stdout io.Writer) error { app.Flag("trace-profile", "Write trace profile to file").Hidden().StringVar(&traceProfile) app.HelpFlag.Short('h') + // Construct the top-level subcommands. versionCmd := app.Command("version", "Print the version of your tbot binary.") kubeCmd := app.Command("kube", "Kubernetes helpers").Hidden() startCmd := app.Command("start", "Starts the renewal bot, writing certificates to the data dir at a set interval.") + configureCmd := app.Command("configure", "Creates a config file based on flags provided, and writes it to stdout or a file (-c ).") configureCmd.Flag("output", "Path to write the generated configuration file to rather than write to stdout.").Short('o').StringVar(&configureOutPath) - // TODO: configureOutPath may have new arg positioning semantics. Verify any change and consider promoting to global - // if necessary. // TODO: consider discarding config flag for non-legacy. These should always be self contained. + // Initialize all new-style commands. var commands []cli.CommandRunner commands = append(commands, cli.NewInitCommand(app, func(init *cli.InitCommand) error { @@ -117,32 +118,34 @@ func Run(args []string, stdout io.Writer) error { // `start` and `configure` commands cli.NewLegacyCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), - cli.NewLegacyCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + cli.NewLegacyCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)), cli.NewIdentityCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), - cli.NewIdentityCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + cli.NewIdentityCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)), cli.NewDatabaseCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), - cli.NewDatabaseCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + cli.NewDatabaseCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)), cli.NewKubernetesCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), - cli.NewKubernetesCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + cli.NewKubernetesCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)), cli.NewApplicationCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), - cli.NewApplicationCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + cli.NewApplicationCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)), cli.NewApplicationTunnelCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), - cli.NewApplicationTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + cli.NewApplicationTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)), cli.NewDatabaseTunnelCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), - cli.NewDatabaseTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + cli.NewDatabaseTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)), cli.NewSPIFFEX509SVIDCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), - cli.NewSPIFFEX509SVIDCommand(configureCmd, buildConfigAndConfigure(ctx, configureOutPath, stdout)), + cli.NewSPIFFEX509SVIDCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)), ) // TODO: workload id / spiffe service subcommand? + // 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) @@ -278,14 +281,14 @@ func buildConfigAndStart(ctx context.Context, globals *cli.GlobalArgs) cli.Mutat // buildConfigAndConfigure returns a MutatorAction that will generate a config // and run `onConfigure` with the result. -func buildConfigAndConfigure(ctx context.Context, outPath string, stdout io.Writer) cli.MutatorAction { +func buildConfigAndConfigure(ctx context.Context, globals *cli.GlobalArgs, outPath *string, stdout io.Writer) cli.MutatorAction { return func(mutator cli.ConfigMutator) error { - cfg, err := cli.BaseConfigWithMutator(mutator) + cfg, err := cli.BaseConfigWithMutators(globals, mutator) if err != nil { return trace.Wrap(err) } - return trace.Wrap(onConfigure(ctx, cfg, outPath, stdout)) + return trace.Wrap(onConfigure(ctx, cfg, *outPath, stdout)) } } From 4b80861ab69c492855e93b99765c4f4363309310 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Mon, 7 Oct 2024 21:29:00 -0600 Subject: [PATCH 05/18] Remove outdated TODO --- lib/tbot/cli/start_identity.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tbot/cli/start_identity.go b/lib/tbot/cli/start_identity.go index 2e707bf8bc153..0263d344e0d08 100644 --- a/lib/tbot/cli/start_identity.go +++ b/lib/tbot/cli/start_identity.go @@ -47,7 +47,7 @@ func NewIdentityCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *Ide 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) - // TODO: roles? ssh_config mode? + // Note: roles and ssh_config mode are excluded for now. return c } From 1761e4fb87b31c3e57b824ea81e966581e94f7b3 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Mon, 7 Oct 2024 21:35:48 -0600 Subject: [PATCH 06/18] Restore TestConfigCLIOnlySample --- lib/tbot/cli/cli_test.go | 50 +++++++++++++++++++++++++++++++ lib/tbot/config/config_storage.go | 5 ++++ lib/tbot/config/config_test.go | 48 ----------------------------- 3 files changed, 55 insertions(+), 48 deletions(-) diff --git a/lib/tbot/cli/cli_test.go b/lib/tbot/cli/cli_test.go index c87ba7db5e040..0406076a3c800 100644 --- a/lib/tbot/cli/cli_test.go +++ b/lib/tbot/cli/cli_test.go @@ -26,6 +26,8 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/tbot/config" "github.com/gravitational/teleport/lib/utils" ) @@ -314,3 +316,51 @@ func TestExecutors(t *testing.T) { }) } } + +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.Equal(t, true, cfg.Debug) + require.Equal(t, legacy.DiagAddr, cfg.DiagAddr) +} diff --git a/lib/tbot/config/config_storage.go b/lib/tbot/config/config_storage.go index d95950ee87d6a..11237a0492187 100644 --- a/lib/tbot/config/config_storage.go +++ b/lib/tbot/config/config_storage.go @@ -61,3 +61,8 @@ func (sc *StorageConfig) UnmarshalYAML(node *yaml.Node) error { sc.Destination = dest return nil } + +// GetDefaultStoragePath returns the default internal storage path for tbot. +func GetDefaultStoragePath() string { + return defaultStoragePath +} diff --git a/lib/tbot/config/config_test.go b/lib/tbot/config/config_test.go index 17cf814e2bff4..9769b89253005 100644 --- a/lib/tbot/config/config_test.go +++ b/lib/tbot/config/config_test.go @@ -35,54 +35,6 @@ import ( "github.com/gravitational/teleport/lib/utils/golden" ) -// TODO move this test to /cli/ to prevent an import cycle -// 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 := cli.LoadConfigWithMutators("") - -// //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) From e629749536c0b35b1c6310679e89b32ba68651fc Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Mon, 7 Oct 2024 21:37:43 -0600 Subject: [PATCH 07/18] Fix lints --- lib/tbot/cli/cli_test.go | 2 +- lib/tbot/cli/db.go | 4 +++- lib/tbot/cli/proxy.go | 8 ++++++-- tool/tbot/init.go | 1 - 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/tbot/cli/cli_test.go b/lib/tbot/cli/cli_test.go index 0406076a3c800..ca217e3806a5f 100644 --- a/lib/tbot/cli/cli_test.go +++ b/lib/tbot/cli/cli_test.go @@ -361,6 +361,6 @@ func TestConfigCLIOnlySample(t *testing.T) { require.True(t, ok) require.Equal(t, legacy.DestinationDir, destImplReal.Path) - require.Equal(t, true, cfg.Debug) + require.True(t, cfg.Debug) require.Equal(t, legacy.DiagAddr, cfg.DiagAddr) } diff --git a/lib/tbot/cli/db.go b/lib/tbot/cli/db.go index eeb39a237ba65..f6ea819b65952 100644 --- a/lib/tbot/cli/db.go +++ b/lib/tbot/cli/db.go @@ -18,6 +18,8 @@ package cli +import "context" + // DBCommand contains fields for `tbot db` type DBCommand struct { *genericExecutorHandler[DBCommand] @@ -43,7 +45,7 @@ func NewDBCommand(app KingpinClause, action func(*DBCommand) error) *DBCommand { // Prepend an action to handle --proxy deprecation. if c.LegacyProxyFlag != "" { c.ProxyServer = c.LegacyProxyFlag - log.Warn("The --proxy flag is deprecated and will be removed in v17.0.0. Use --proxy-server instead") + log.WarnContext(context.TODO(), "The --proxy flag is deprecated and will be removed in v17.0.0. Use --proxy-server instead") } return nil diff --git a/lib/tbot/cli/proxy.go b/lib/tbot/cli/proxy.go index 0463c1a1df91d..61d383b580855 100644 --- a/lib/tbot/cli/proxy.go +++ b/lib/tbot/cli/proxy.go @@ -18,7 +18,11 @@ package cli -import "github.com/alecthomas/kingpin/v2" +import ( + "context" + + "github.com/alecthomas/kingpin/v2" +) // ProxyCommand supports `tbot proxy` type ProxyCommand struct { @@ -45,7 +49,7 @@ func NewProxyCommand(app *kingpin.Application, action func(*ProxyCommand) error) // Prepend an action to handle --proxy deprecation. if c.LegacyProxyFlag != "" { c.ProxyServer = c.LegacyProxyFlag - log.Warn("The --proxy flag is deprecated and will be removed in v17.0.0. Use --proxy-server instead") + log.WarnContext(context.TODO(), "The --proxy flag is deprecated and will be removed in v17.0.0. Use --proxy-server instead") } return nil diff --git a/tool/tbot/init.go b/tool/tbot/init.go index 06f574d89e262..04adddd620014 100644 --- a/tool/tbot/init.go +++ b/tool/tbot/init.go @@ -431,7 +431,6 @@ func onInit(globals *cli.GlobalArgs, init *cli.InitCommand) error { defer cancel() initables := botConfig.GetInitables() - log.Warn("initables", "initables", initables) 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, From 59d5d94b3b9660d47a0d67357653d6da4cb1954d Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Mon, 7 Oct 2024 22:00:30 -0600 Subject: [PATCH 08/18] Fix missing embedded flag init --- lib/tbot/cli/start_legacy.go | 1 + lib/tbot/cli/start_shared.go | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/tbot/cli/start_legacy.go b/lib/tbot/cli/start_legacy.go index 33d3e88390fbe..d4d2de6119a62 100644 --- a/lib/tbot/cli/start_legacy.go +++ b/lib/tbot/cli/start_legacy.go @@ -146,6 +146,7 @@ func NewLegacyCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *Legac 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) diff --git a/lib/tbot/cli/start_shared.go b/lib/tbot/cli/start_shared.go index 1b849f872a351..b179ea80c598f 100644 --- a/lib/tbot/cli/start_shared.go +++ b/lib/tbot/cli/start_shared.go @@ -114,6 +114,7 @@ type sharedStartArgs struct { // 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)", From 3c98f037c48ee4e28460905ef8f1ccd6acd56d39 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Tue, 8 Oct 2024 17:47:20 -0600 Subject: [PATCH 09/18] Code review feedback --- lib/tbot/cli/globals.go | 4 ---- lib/tbot/cli/start_spiffe_x509_svid.go | 4 ++-- tool/tbot/main.go | 2 -- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/tbot/cli/globals.go b/lib/tbot/cli/globals.go index a4d25cdee9db3..dcd762920daa0 100644 --- a/lib/tbot/cli/globals.go +++ b/lib/tbot/cli/globals.go @@ -102,10 +102,6 @@ func (g *GlobalArgs) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error { cfg.Debug = g.Debug } - // TODO: in previous versions, `insecure` is handled _after_ - // BotConfig.CheckAndSetDefaults(). This flag is checked and setting it here - // *will* cause a behavioral change, so make sure the new behavior is sane. - // (It is unclear why this was done.) if g.Insecure { cfg.Insecure = true } diff --git a/lib/tbot/cli/start_spiffe_x509_svid.go b/lib/tbot/cli/start_spiffe_x509_svid.go index 9246c971f1cb9..adb930480c69e 100644 --- a/lib/tbot/cli/start_spiffe_x509_svid.go +++ b/lib/tbot/cli/start_spiffe_x509_svid.go @@ -53,8 +53,8 @@ func NewSPIFFEX509SVIDCommand(parentCmd *kingpin.CmdClause, action MutatorAction 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("request-dns-san", "A DNS name that should be included in the SVID. Repeatable.").StringsVar(&c.DNSSANs) - cmd.Flag("request-ip-san", "An IP address that should be included in the SVID. Repeatable.").StringsVar(&c.IPSANs) + 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 } diff --git a/tool/tbot/main.go b/tool/tbot/main.go index 02d745de2af04..336e6046beadb 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -142,8 +142,6 @@ func Run(args []string, stdout io.Writer) error { cli.NewSPIFFEX509SVIDCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)), ) - // TODO: workload id / spiffe service subcommand? - // Initialize legacy-style commands. These are simple enough to not really // benefit from conversion to a new-style command. spiffeInspectPath := "" From 8fc0154259b4a3df1463b56bb4128e197dd6ca7c Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Tue, 8 Oct 2024 17:54:19 -0600 Subject: [PATCH 10/18] Add docstrings --- lib/tbot/cli/kube_credentials.go | 2 ++ lib/tbot/cli/start_application.go | 2 ++ lib/tbot/cli/start_application_tunnel.go | 2 ++ lib/tbot/cli/start_database.go | 2 ++ lib/tbot/cli/start_identity.go | 2 ++ lib/tbot/cli/start_kubernetes.go | 2 ++ lib/tbot/cli/start_shared.go | 4 ++++ lib/tbot/cli/start_spiffe_x509_svid.go | 3 +++ 8 files changed, 19 insertions(+) diff --git a/lib/tbot/cli/kube_credentials.go b/lib/tbot/cli/kube_credentials.go index 88906003daa38..83c7134e1ddf0 100644 --- a/lib/tbot/cli/kube_credentials.go +++ b/lib/tbot/cli/kube_credentials.go @@ -24,6 +24,8 @@ type KubeCredentialsCommand struct { 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() diff --git a/lib/tbot/cli/start_application.go b/lib/tbot/cli/start_application.go index a29e8c6b2b2aa..b5c684c6beda5 100644 --- a/lib/tbot/cli/start_application.go +++ b/lib/tbot/cli/start_application.go @@ -38,6 +38,8 @@ type ApplicationCommand struct { SpecificTLSExtensions bool } +// NewApplicationCommand initalizes 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") diff --git a/lib/tbot/cli/start_application_tunnel.go b/lib/tbot/cli/start_application_tunnel.go index 9874aae0f7a6b..2d6ea0541b734 100644 --- a/lib/tbot/cli/start_application_tunnel.go +++ b/lib/tbot/cli/start_application_tunnel.go @@ -37,6 +37,8 @@ type ApplicationTunnelCommand struct { 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") diff --git a/lib/tbot/cli/start_database.go b/lib/tbot/cli/start_database.go index 199b5abee89b2..01bc0fd8fb600 100644 --- a/lib/tbot/cli/start_database.go +++ b/lib/tbot/cli/start_database.go @@ -40,6 +40,8 @@ type DatabaseCommand struct { 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") diff --git a/lib/tbot/cli/start_identity.go b/lib/tbot/cli/start_identity.go index 0263d344e0d08..0898c998f1fee 100644 --- a/lib/tbot/cli/start_identity.go +++ b/lib/tbot/cli/start_identity.go @@ -37,6 +37,8 @@ type IdentityCommand struct { 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") diff --git a/lib/tbot/cli/start_kubernetes.go b/lib/tbot/cli/start_kubernetes.go index abd1db41a4753..028a09fb649a1 100644 --- a/lib/tbot/cli/start_kubernetes.go +++ b/lib/tbot/cli/start_kubernetes.go @@ -38,6 +38,8 @@ type KubernetesCommand struct { 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") diff --git a/lib/tbot/cli/start_shared.go b/lib/tbot/cli/start_shared.go index b179ea80c598f..69ac3d05dab8a 100644 --- a/lib/tbot/cli/start_shared.go +++ b/lib/tbot/cli/start_shared.go @@ -56,6 +56,10 @@ func NewStaticAuthServer(authServer string) *AuthProxyArgs { } } +// 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{} diff --git a/lib/tbot/cli/start_spiffe_x509_svid.go b/lib/tbot/cli/start_spiffe_x509_svid.go index adb930480c69e..237a3ff02bf0b 100644 --- a/lib/tbot/cli/start_spiffe_x509_svid.go +++ b/lib/tbot/cli/start_spiffe_x509_svid.go @@ -42,6 +42,9 @@ type SPIFFEX509SVIDCommand struct { IPSANs []string } +// NewSPIFFEX509SVIDCommand initializes the command and flags for the +// `spiffe-x509-svid` output and returns a struct that will contain the parse +// result. func NewSPIFFEX509SVIDCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *SPIFFEX509SVIDCommand { cmd := parentCmd.Command("spiffe-x509-svid", "Starts with a SPIFFE-compatible X509 SVID output") From 807d2d527543cbd98ca418caa52613cddee80f75 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Tue, 8 Oct 2024 19:53:58 -0600 Subject: [PATCH 11/18] 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. --- lib/tbot/cli/globals_test.go | 65 +++++++++++ lib/tbot/cli/start_application_test.go | 74 +++++++++++++ lib/tbot/cli/start_application_tunnel_test.go | 70 ++++++++++++ lib/tbot/cli/start_database_test.go | 78 +++++++++++++ lib/tbot/cli/start_database_tunnel_test.go | 73 +++++++++++++ lib/tbot/cli/start_identity_test.go | 103 ++++++++++++++++++ lib/tbot/cli/start_kubernetes_test.go | 74 +++++++++++++ lib/tbot/cli/start_legacy.go | 3 - lib/tbot/cli/start_legacy_test.go | 102 +++++++++++++++++ lib/tbot/cli/start_shared.go | 13 +++ lib/tbot/cli/start_shared_test.go | 75 +++++++++++++ lib/tbot/cli/start_spiffe_x509_svid_test.go | 86 +++++++++++++++ 12 files changed, 813 insertions(+), 3 deletions(-) create mode 100644 lib/tbot/cli/globals_test.go create mode 100644 lib/tbot/cli/start_application_test.go create mode 100644 lib/tbot/cli/start_application_tunnel_test.go create mode 100644 lib/tbot/cli/start_database_test.go create mode 100644 lib/tbot/cli/start_database_tunnel_test.go create mode 100644 lib/tbot/cli/start_identity_test.go create mode 100644 lib/tbot/cli/start_kubernetes_test.go create mode 100644 lib/tbot/cli/start_legacy_test.go create mode 100644 lib/tbot/cli/start_shared_test.go create mode 100644 lib/tbot/cli/start_spiffe_x509_svid_test.go diff --git a/lib/tbot/cli/globals_test.go b/lib/tbot/cli/globals_test.go new file mode 100644 index 0000000000000..06fdc5256a82d --- /dev/null +++ b/lib/tbot/cli/globals_test.go @@ -0,0 +1,65 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestGlobals tests that GlobalArgs initialize and parse properly, and mutate +// BotConfig as expected. +func TestGlobalArgs(t *testing.T) { + app, _ := buildMinimalKingpinApp("start") + globals := NewGlobalArgs(app) + + // Note: various flags here are already tested as part of sharedStartArgs. + _, err := app.Parse([]string{ + "start", + "--debug", + "--config=foo.yaml", + "--fips", + "--trace", + "--trace-exporter=foo", + "--insecure", + "--log-format=json", + }) + require.NoError(t, err) + + require.True(t, globals.Debug) + require.True(t, globals.FIPS) + require.True(t, globals.Insecure) + require.True(t, globals.Trace) + require.Equal(t, "foo", globals.TraceExporter) + require.Equal(t, "foo.yaml", globals.ConfigPath) + + // Clear the config path, otherwise LoadConfigWithMutators will try to load + // it. + globals.ConfigPath = "" + + // Convert these args to a BotConfig and check it. Globals don't set many + // config flags, so not much to check here. + cfg, err := LoadConfigWithMutators(globals) + require.NoError(t, err) + + require.True(t, cfg.Debug) + require.True(t, cfg.FIPS) + require.True(t, cfg.Insecure) +} diff --git a/lib/tbot/cli/start_application_test.go b/lib/tbot/cli/start_application_test.go new file mode 100644 index 0000000000000..bdb5f1e6a2b43 --- /dev/null +++ b/lib/tbot/cli/start_application_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/gravitational/teleport/lib/tbot/config" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestApplicationCommand tests that the ApplicationCommand properly parses its +// arguments and applies as expected onto a BotConfig. +func TestApplicationCommand(t *testing.T) { + mockAction := configMutatorMock{} + mockAction.On("action", mock.Anything).Return(nil) + + app, subcommand := buildMinimalKingpinApp("start") + cmd := NewApplicationCommand(subcommand, mockAction.action) + + // Note: various flags here are already tested as part of sharedStartArgs. + command, err := app.Parse([]string{ + "start", + "application", + "--destination=/bar", + "--token=foo", + "--join-method=github", + "--proxy-server=example.com:443", + "--app=foo", + "--specific-tls-extensions", + }) + require.NoError(t, err) + + match, err := cmd.TryRun(command) + require.NoError(t, err) + require.True(t, match) + + mockAction.AssertCalled(t, "action", mock.Anything) + + // Convert these args to a BotConfig and check it. + cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) + require.NoError(t, err) + + require.Len(t, cfg.Services, 1) + + // 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_test.go b/lib/tbot/cli/start_application_tunnel_test.go new file mode 100644 index 0000000000000..bfbb0cb88f256 --- /dev/null +++ b/lib/tbot/cli/start_application_tunnel_test.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 ( + "testing" + + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestApplicationTunnelCommand tests that the ApplicationTunnelCommand +// properly parses its arguments and applies as expected onto a BotConfig. +func TestApplicationTunnelCommand(t *testing.T) { + mockAction := configMutatorMock{} + mockAction.On("action", mock.Anything).Return(nil) + + app, subcommand := buildMinimalKingpinApp("start") + cmd := NewApplicationTunnelCommand(subcommand, mockAction.action) + + // Note: various flags here are already tested as part of sharedStartArgs. + command, err := app.Parse([]string{ + "start", + "application-tunnel", + "--token=foo", + "--join-method=github", + "--proxy-server=example.com:443", + "--app=foo", + "--listen=tcp://0.0.0.0:8000", + }) + require.NoError(t, err) + + match, err := cmd.TryRun(command) + require.NoError(t, err) + require.True(t, match) + + mockAction.AssertCalled(t, "action", mock.Anything) + + // Convert these args to a BotConfig and check it. + cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) + require.NoError(t, err) + + 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_test.go b/lib/tbot/cli/start_database_test.go new file mode 100644 index 0000000000000..75c37060f6de1 --- /dev/null +++ b/lib/tbot/cli/start_database_test.go @@ -0,0 +1,78 @@ +/* + * 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/gravitational/teleport/lib/tbot/config" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestDatabaseCommand tests that the DatabaseCommand properly parses its +// arguments and applies as expected onto a BotConfig. +func TestDatabaseCommand(t *testing.T) { + mockAction := configMutatorMock{} + mockAction.On("action", mock.Anything).Return(nil) + + app, subcommand := buildMinimalKingpinApp("start") + cmd := NewDatabaseCommand(subcommand, mockAction.action) + + // Note: various flags here are already tested as part of sharedStartArgs. + command, err := app.Parse([]string{ + "start", + "database", + "--destination=/bar", + "--token=foo", + "--join-method=github", + "--proxy-server=example.com:443", + "--service=foo", + "--username=bar", + "--database=baz", + "--format=tls", + }) + require.NoError(t, err) + + match, err := cmd.TryRun(command) + require.NoError(t, err) + require.True(t, match) + + mockAction.AssertCalled(t, "action", mock.Anything) + + // Convert these args to a BotConfig and check it. + cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) + require.NoError(t, err) + + 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_test.go b/lib/tbot/cli/start_database_tunnel_test.go new file mode 100644 index 0000000000000..8af762a0cda50 --- /dev/null +++ b/lib/tbot/cli/start_database_tunnel_test.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 ( + "testing" + + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestDatabaseTunnelCommand tests that the DatabaseTunnelCommand +// properly parses its arguments and applies as expected onto a BotConfig. +func TestDatabaseTunnelCommand(t *testing.T) { + mockAction := configMutatorMock{} + mockAction.On("action", mock.Anything).Return(nil) + + app, subcommand := buildMinimalKingpinApp("start") + cmd := NewDatabaseTunnelCommand(subcommand, mockAction.action) + + // Note: various flags here are already tested as part of sharedStartArgs. + command, err := app.Parse([]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", + }) + require.NoError(t, err) + + match, err := cmd.TryRun(command) + require.NoError(t, err) + require.True(t, match) + + mockAction.AssertCalled(t, "action", mock.Anything) + + // Convert these args to a BotConfig and check it. + cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) + require.NoError(t, err) + + 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_test.go b/lib/tbot/cli/start_identity_test.go new file mode 100644 index 0000000000000..4ccc7188cc7a0 --- /dev/null +++ b/lib/tbot/cli/start_identity_test.go @@ -0,0 +1,103 @@ +/* + * 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/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestIdentityCommand tests that the IdentityCommand properly parses its arguments +// and applies as expected onto a BotConfig. +func TestIdentityCommand(t *testing.T) { + mockAction := configMutatorMock{} + mockAction.On("action", mock.Anything).Return(nil) + + app, subcommand := buildMinimalKingpinApp("start") + cmd := NewIdentityCommand(subcommand, mockAction.action) + + command, err := app.Parse([]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", + }) + require.NoError(t, err) + + match, err := cmd.TryRun(command) + require.NoError(t, err) + require.True(t, match) + + mockAction.AssertCalled(t, "action", mock.Anything) + + require.Equal(t, "foo", cmd.Token) + require.Len(t, cmd.CAPins, 1) + require.Equal(t, "bar", cmd.CAPins[0]) + require.Equal(t, time.Minute*10, cmd.CertificateTTL) + require.Equal(t, time.Minute*5, cmd.RenewalInterval) + require.True(t, cmd.Oneshot) + require.Equal(t, "0.0.0.0:8080", cmd.DiagAddr) + require.Equal(t, "/foo", cmd.Storage) + require.Equal(t, "file:///bar", cmd.Destination) + require.Equal(t, "example.com:443", cmd.ProxyServer) + + // Convert these args to a BotConfig and check it. + cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) + 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) + 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_test.go b/lib/tbot/cli/start_kubernetes_test.go new file mode 100644 index 0000000000000..ccc4f1ec88708 --- /dev/null +++ b/lib/tbot/cli/start_kubernetes_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/gravitational/teleport/lib/tbot/config" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestKubernetesCommand tests that the KubernetesCommand properly parses its +// arguments and applies as expected onto a BotConfig. +func TestKubernetesCommand(t *testing.T) { + mockAction := configMutatorMock{} + mockAction.On("action", mock.Anything).Return(nil) + + app, subcommand := buildMinimalKingpinApp("start") + cmd := NewKubernetesCommand(subcommand, mockAction.action) + + // Note: various flags here are already tested as part of sharedStartArgs. + command, err := app.Parse([]string{ + "start", + "kubernetes", + "--destination=/bar", + "--token=foo", + "--join-method=github", + "--proxy-server=example.com:443", + "--kubernetes-cluster=demo", + "--disable-exec-plugin", + }) + require.NoError(t, err) + + match, err := cmd.TryRun(command) + require.NoError(t, err) + require.True(t, match) + + mockAction.AssertCalled(t, "action", mock.Anything) + + // Convert these args to a BotConfig and check it. + cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) + require.NoError(t, err) + + 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 index d4d2de6119a62..95f412bdf82f2 100644 --- a/lib/tbot/cli/start_legacy.go +++ b/lib/tbot/cli/start_legacy.go @@ -103,9 +103,6 @@ type LegacyCommand struct { // DataDir stores the bot's internal data. DataDir string - // Destination is a destination URI - Destination string - // CAPins is a list of pinned SKPI hashes of trusted auth server CAs, used // only on first connect. CAPins []string diff --git a/lib/tbot/cli/start_legacy_test.go b/lib/tbot/cli/start_legacy_test.go new file mode 100644 index 0000000000000..dcd6a9f5ea70b --- /dev/null +++ b/lib/tbot/cli/start_legacy_test.go @@ -0,0 +1,102 @@ +/* + * 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/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestLegacyCommand tests that the LegacyCommand properly parses its arguments +// and applies as expected onto a BotConfig. +func TestLegacyCommand(t *testing.T) { + mockAction := configMutatorMock{} + mockAction.On("action", mock.Anything).Return(nil) + + app, subcommand := buildMinimalKingpinApp("start") + legacy := NewLegacyCommand(subcommand, mockAction.action) + + command, err := app.Parse([]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", + }) + require.NoError(t, err) + + match, err := legacy.TryRun(command) + require.NoError(t, err) + require.True(t, match) + + mockAction.AssertCalled(t, "action", mock.Anything) + + require.Equal(t, "foo", legacy.Token) + require.Len(t, legacy.CAPins, 1) + require.Equal(t, "bar", legacy.CAPins[0]) + require.Equal(t, time.Minute*10, legacy.CertificateTTL) + require.Equal(t, time.Minute*5, legacy.RenewalInterval) + require.True(t, legacy.Oneshot) + require.Equal(t, "0.0.0.0:8080", legacy.DiagAddr) + require.Equal(t, "/foo", legacy.DataDir) + require.Equal(t, "/bar", legacy.DestinationDir) + require.Equal(t, "example.com:3024", legacy.AuthServer) + + // Convert these args to a BotConfig and check it. + cfg, err := LoadConfigWithMutators(&GlobalArgs{}, legacy) + 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) + 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 index 69ac3d05dab8a..310405990c881 100644 --- a/lib/tbot/cli/start_shared.go +++ b/lib/tbot/cli/start_shared.go @@ -176,6 +176,19 @@ func (s *sharedStartArgs) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) err 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 { diff --git a/lib/tbot/cli/start_shared_test.go b/lib/tbot/cli/start_shared_test.go new file mode 100644 index 0000000000000..9365f627ab55c --- /dev/null +++ b/lib/tbot/cli/start_shared_test.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 ( + "testing" + "time" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/stretchr/testify/require" +) + +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_x509_svid_test.go b/lib/tbot/cli/start_spiffe_x509_svid_test.go new file mode 100644 index 0000000000000..4fa7478ec453e --- /dev/null +++ b/lib/tbot/cli/start_spiffe_x509_svid_test.go @@ -0,0 +1,86 @@ +/* + * 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/gravitational/teleport/lib/tbot/config" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestSPIFFEX509SVIDCommand tests that the SPIFFEX509SVIDCommand properly parses its +// arguments and applies as expected onto a BotConfig. +func TestSPIFFEX509SVIDCommand(t *testing.T) { + mockAction := configMutatorMock{} + mockAction.On("action", mock.Anything).Return(nil) + + app, subcommand := buildMinimalKingpinApp("start") + cmd := NewSPIFFEX509SVIDCommand(subcommand, mockAction.action) + + // Note: various flags here are already tested as part of sharedStartArgs. + command, err := app.Parse([]string{ + "start", + "spiffe-x509-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", + }) + require.NoError(t, err) + + match, err := cmd.TryRun(command) + require.NoError(t, err) + require.True(t, match) + + mockAction.AssertCalled(t, "action", mock.Anything) + + // Convert these args to a BotConfig and check it. + cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) + require.NoError(t, err) + + 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) +} From 09b7afe88e4a64a9b16e21f5d0b62885f9b760c7 Mon Sep 17 00:00:00 2001 From: Tim Buckley Date: Tue, 8 Oct 2024 20:07:07 -0600 Subject: [PATCH 12/18] Fix imports --- lib/tbot/cli/start_application_test.go | 3 ++- lib/tbot/cli/start_application_tunnel_test.go | 3 ++- lib/tbot/cli/start_database_test.go | 3 ++- lib/tbot/cli/start_database_tunnel_test.go | 3 ++- lib/tbot/cli/start_identity_test.go | 5 +++-- lib/tbot/cli/start_kubernetes_test.go | 3 ++- lib/tbot/cli/start_legacy_test.go | 5 +++-- lib/tbot/cli/start_shared_test.go | 3 ++- lib/tbot/cli/start_spiffe_x509_svid_test.go | 3 ++- 9 files changed, 20 insertions(+), 11 deletions(-) diff --git a/lib/tbot/cli/start_application_test.go b/lib/tbot/cli/start_application_test.go index bdb5f1e6a2b43..1789ed3078b55 100644 --- a/lib/tbot/cli/start_application_test.go +++ b/lib/tbot/cli/start_application_test.go @@ -21,9 +21,10 @@ package cli import ( "testing" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/tbot/config" ) // TestApplicationCommand tests that the ApplicationCommand properly parses its diff --git a/lib/tbot/cli/start_application_tunnel_test.go b/lib/tbot/cli/start_application_tunnel_test.go index bfbb0cb88f256..a68f5d6e2d79f 100644 --- a/lib/tbot/cli/start_application_tunnel_test.go +++ b/lib/tbot/cli/start_application_tunnel_test.go @@ -21,9 +21,10 @@ package cli import ( "testing" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/tbot/config" ) // TestApplicationTunnelCommand tests that the ApplicationTunnelCommand diff --git a/lib/tbot/cli/start_database_test.go b/lib/tbot/cli/start_database_test.go index 75c37060f6de1..3a1324b820c9e 100644 --- a/lib/tbot/cli/start_database_test.go +++ b/lib/tbot/cli/start_database_test.go @@ -21,9 +21,10 @@ package cli import ( "testing" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/tbot/config" ) // TestDatabaseCommand tests that the DatabaseCommand properly parses its diff --git a/lib/tbot/cli/start_database_tunnel_test.go b/lib/tbot/cli/start_database_tunnel_test.go index 8af762a0cda50..29d002635f35b 100644 --- a/lib/tbot/cli/start_database_tunnel_test.go +++ b/lib/tbot/cli/start_database_tunnel_test.go @@ -21,9 +21,10 @@ package cli import ( "testing" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/tbot/config" ) // TestDatabaseTunnelCommand tests that the DatabaseTunnelCommand diff --git a/lib/tbot/cli/start_identity_test.go b/lib/tbot/cli/start_identity_test.go index 4ccc7188cc7a0..827da5e7d107c 100644 --- a/lib/tbot/cli/start_identity_test.go +++ b/lib/tbot/cli/start_identity_test.go @@ -22,10 +22,11 @@ import ( "testing" "time" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/stretchr/testify/mock" "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 diff --git a/lib/tbot/cli/start_kubernetes_test.go b/lib/tbot/cli/start_kubernetes_test.go index ccc4f1ec88708..6f6f8527adc10 100644 --- a/lib/tbot/cli/start_kubernetes_test.go +++ b/lib/tbot/cli/start_kubernetes_test.go @@ -21,9 +21,10 @@ package cli import ( "testing" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/tbot/config" ) // TestKubernetesCommand tests that the KubernetesCommand properly parses its diff --git a/lib/tbot/cli/start_legacy_test.go b/lib/tbot/cli/start_legacy_test.go index dcd6a9f5ea70b..f842f84904300 100644 --- a/lib/tbot/cli/start_legacy_test.go +++ b/lib/tbot/cli/start_legacy_test.go @@ -22,10 +22,11 @@ import ( "testing" "time" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/stretchr/testify/mock" "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 diff --git a/lib/tbot/cli/start_shared_test.go b/lib/tbot/cli/start_shared_test.go index 9365f627ab55c..5ec8cbd2f3d0d 100644 --- a/lib/tbot/cli/start_shared_test.go +++ b/lib/tbot/cli/start_shared_test.go @@ -22,9 +22,10 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/tbot/config" - "github.com/stretchr/testify/require" ) func TestSharedStartArgs(t *testing.T) { diff --git a/lib/tbot/cli/start_spiffe_x509_svid_test.go b/lib/tbot/cli/start_spiffe_x509_svid_test.go index 4fa7478ec453e..4f2b2ae2bb462 100644 --- a/lib/tbot/cli/start_spiffe_x509_svid_test.go +++ b/lib/tbot/cli/start_spiffe_x509_svid_test.go @@ -21,9 +21,10 @@ package cli import ( "testing" - "github.com/gravitational/teleport/lib/tbot/config" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/tbot/config" ) // TestSPIFFEX509SVIDCommand tests that the SPIFFEX509SVIDCommand properly parses its From 8b193406432cd80277f06f6b8a7f0c2db2a2e58d Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 9 Oct 2024 09:55:11 +0100 Subject: [PATCH 13/18] Add helper for repetitive test --- lib/tbot/cli/cli_test.go | 73 ++++++++--- lib/tbot/cli/start_application_test.go | 69 ++++------- lib/tbot/cli/start_application_tunnel_test.go | 62 ++++------ lib/tbot/cli/start_database_test.go | 75 +++++------- lib/tbot/cli/start_database_tunnel_test.go | 69 +++++------ lib/tbot/cli/start_identity_test.go | 115 +++++++----------- lib/tbot/cli/start_kubernetes_test.go | 71 +++++------ lib/tbot/cli/start_legacy_test.go | 113 +++++++---------- ...iffe_x509_svid.go => start_spiffe_svid.go} | 18 +-- lib/tbot/cli/start_spiffe_svid_test.go | 74 +++++++++++ lib/tbot/cli/start_spiffe_x509_svid_test.go | 87 ------------- tool/tbot/main.go | 4 +- 12 files changed, 370 insertions(+), 460 deletions(-) rename lib/tbot/cli/{start_spiffe_x509_svid.go => start_spiffe_svid.go} (79%) create mode 100644 lib/tbot/cli/start_spiffe_svid_test.go delete mode 100644 lib/tbot/cli/start_spiffe_x509_svid_test.go diff --git a/lib/tbot/cli/cli_test.go b/lib/tbot/cli/cli_test.go index ca217e3806a5f..b79ba564477a2 100644 --- a/lib/tbot/cli/cli_test.go +++ b/lib/tbot/cli/cli_test.go @@ -20,6 +20,7 @@ package cli import ( "fmt" + "log/slog" "testing" "github.com/alecthomas/kingpin/v2" @@ -31,15 +32,6 @@ import ( "github.com/gravitational/teleport/lib/utils" ) -type configMutatorMock struct { - mock.Mock -} - -func (m *configMutatorMock) action(mut ConfigMutator) error { - args := m.Called(mut) - return args.Error(0) -} - type genericExecutorMock[T any] struct { mock.Mock } @@ -137,10 +129,10 @@ func TestConfigMutators(t *testing.T) { {"spiffe-x509-svid", "--destination=foo", "--svid-path=/bar"}, }, buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { - return NewSPIFFEX509SVIDCommand(parent, callback) + return NewSPIFFESVIDCommand(parent, callback) }, assert: func(t *testing.T, value any) { - require.IsType(t, &SPIFFEX509SVIDCommand{}, value) + require.IsType(t, &SPIFFESVIDCommand{}, value) }, }, { @@ -182,10 +174,13 @@ func TestConfigMutators(t *testing.T) { subcommandName := "sub" app, subcommand := buildMinimalKingpinApp(subcommandName) - mockAction := configMutatorMock{} - mockAction.On("action", mock.Anything).Return(nil) - - runner := tt.buildCommand(subcommand, mockAction.action) + actionCalled := false + var actionCalledMutator ConfigMutator + runner := tt.buildCommand(subcommand, func(mutator ConfigMutator) error { + actionCalled = true + actionCalledMutator = mutator + return nil + }) command, err := app.Parse(append([]string{subcommandName}, argSet...)) require.NoError(t, err) @@ -194,10 +189,9 @@ func TestConfigMutators(t *testing.T) { require.NoError(t, err) require.True(t, match) - mockAction.AssertCalled(t, "action", mock.Anything) + require.True(t, actionCalled) - arg := mockAction.Calls[0].Arguments.Get(0) - tt.assert(t, arg) + tt.assert(t, actionCalledMutator) }) } }) @@ -364,3 +358,46 @@ func TestConfigCLIOnlySample(t *testing.T) { require.True(t, cfg.Debug) require.Equal(t, legacy.DiagAddr, cfg.DiagAddr) } + +type startConfigureCommand interface { + ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error + TryRun(cmd string) (match bool, err error) +} + +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/start_application_test.go b/lib/tbot/cli/start_application_test.go index 1789ed3078b55..1d0b562621af1 100644 --- a/lib/tbot/cli/start_application_test.go +++ b/lib/tbot/cli/start_application_test.go @@ -21,7 +21,6 @@ package cli import ( "testing" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/lib/tbot/config" @@ -30,46 +29,32 @@ import ( // TestApplicationCommand tests that the ApplicationCommand properly parses its // arguments and applies as expected onto a BotConfig. func TestApplicationCommand(t *testing.T) { - mockAction := configMutatorMock{} - mockAction.On("action", mock.Anything).Return(nil) - - app, subcommand := buildMinimalKingpinApp("start") - cmd := NewApplicationCommand(subcommand, mockAction.action) - - // Note: various flags here are already tested as part of sharedStartArgs. - command, err := app.Parse([]string{ - "start", - "application", - "--destination=/bar", - "--token=foo", - "--join-method=github", - "--proxy-server=example.com:443", - "--app=foo", - "--specific-tls-extensions", + 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) + }, + }, }) - require.NoError(t, err) - - match, err := cmd.TryRun(command) - require.NoError(t, err) - require.True(t, match) - - mockAction.AssertCalled(t, "action", mock.Anything) - - // Convert these args to a BotConfig and check it. - cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) - require.NoError(t, err) - - require.Len(t, cfg.Services, 1) - - // 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_test.go b/lib/tbot/cli/start_application_tunnel_test.go index a68f5d6e2d79f..049c18d9864a6 100644 --- a/lib/tbot/cli/start_application_tunnel_test.go +++ b/lib/tbot/cli/start_application_tunnel_test.go @@ -21,7 +21,6 @@ package cli import ( "testing" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/lib/tbot/config" @@ -30,42 +29,29 @@ import ( // TestApplicationTunnelCommand tests that the ApplicationTunnelCommand // properly parses its arguments and applies as expected onto a BotConfig. func TestApplicationTunnelCommand(t *testing.T) { - mockAction := configMutatorMock{} - mockAction.On("action", mock.Anything).Return(nil) - - app, subcommand := buildMinimalKingpinApp("start") - cmd := NewApplicationTunnelCommand(subcommand, mockAction.action) - - // Note: various flags here are already tested as part of sharedStartArgs. - command, err := app.Parse([]string{ - "start", - "application-tunnel", - "--token=foo", - "--join-method=github", - "--proxy-server=example.com:443", - "--app=foo", - "--listen=tcp://0.0.0.0:8000", + 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) + }, + }, }) - require.NoError(t, err) - - match, err := cmd.TryRun(command) - require.NoError(t, err) - require.True(t, match) - - mockAction.AssertCalled(t, "action", mock.Anything) - - // Convert these args to a BotConfig and check it. - cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) - require.NoError(t, err) - - 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_test.go b/lib/tbot/cli/start_database_test.go index 3a1324b820c9e..89008d623ace8 100644 --- a/lib/tbot/cli/start_database_test.go +++ b/lib/tbot/cli/start_database_test.go @@ -21,7 +21,6 @@ package cli import ( "testing" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/lib/tbot/config" @@ -30,50 +29,38 @@ import ( // TestDatabaseCommand tests that the DatabaseCommand properly parses its // arguments and applies as expected onto a BotConfig. func TestDatabaseCommand(t *testing.T) { - mockAction := configMutatorMock{} - mockAction.On("action", mock.Anything).Return(nil) + 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) - app, subcommand := buildMinimalKingpinApp("start") - cmd := NewDatabaseCommand(subcommand, mockAction.action) + // It must configure a database output with a directory destination. + svc := cfg.Services[0] + db, ok := svc.(*config.DatabaseOutput) + require.True(t, ok) - // Note: various flags here are already tested as part of sharedStartArgs. - command, err := app.Parse([]string{ - "start", - "database", - "--destination=/bar", - "--token=foo", - "--join-method=github", - "--proxy-server=example.com:443", - "--service=foo", - "--username=bar", - "--database=baz", - "--format=tls", - }) - require.NoError(t, err) - - match, err := cmd.TryRun(command) - require.NoError(t, err) - require.True(t, match) - - mockAction.AssertCalled(t, "action", mock.Anything) - - // Convert these args to a BotConfig and check it. - cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) - require.NoError(t, err) + 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) - 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) + 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_test.go b/lib/tbot/cli/start_database_tunnel_test.go index 29d002635f35b..7e15dc5fe2782 100644 --- a/lib/tbot/cli/start_database_tunnel_test.go +++ b/lib/tbot/cli/start_database_tunnel_test.go @@ -21,7 +21,6 @@ package cli import ( "testing" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/lib/tbot/config" @@ -30,45 +29,33 @@ import ( // TestDatabaseTunnelCommand tests that the DatabaseTunnelCommand // properly parses its arguments and applies as expected onto a BotConfig. func TestDatabaseTunnelCommand(t *testing.T) { - mockAction := configMutatorMock{} - mockAction.On("action", mock.Anything).Return(nil) - - app, subcommand := buildMinimalKingpinApp("start") - cmd := NewDatabaseTunnelCommand(subcommand, mockAction.action) - - // Note: various flags here are already tested as part of sharedStartArgs. - command, err := app.Parse([]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", + 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) + }, + }, }) - require.NoError(t, err) - - match, err := cmd.TryRun(command) - require.NoError(t, err) - require.True(t, match) - - mockAction.AssertCalled(t, "action", mock.Anything) - - // Convert these args to a BotConfig and check it. - cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) - require.NoError(t, err) - - 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_test.go b/lib/tbot/cli/start_identity_test.go index 827da5e7d107c..606b07babc202 100644 --- a/lib/tbot/cli/start_identity_test.go +++ b/lib/tbot/cli/start_identity_test.go @@ -22,7 +22,6 @@ import ( "testing" "time" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/api/types" @@ -32,73 +31,51 @@ import ( // TestIdentityCommand tests that the IdentityCommand properly parses its arguments // and applies as expected onto a BotConfig. func TestIdentityCommand(t *testing.T) { - mockAction := configMutatorMock{} - mockAction.On("action", mock.Anything).Return(nil) - - app, subcommand := buildMinimalKingpinApp("start") - cmd := NewIdentityCommand(subcommand, mockAction.action) - - command, err := app.Parse([]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", + 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) + }, + }, }) - require.NoError(t, err) - - match, err := cmd.TryRun(command) - require.NoError(t, err) - require.True(t, match) - - mockAction.AssertCalled(t, "action", mock.Anything) - - require.Equal(t, "foo", cmd.Token) - require.Len(t, cmd.CAPins, 1) - require.Equal(t, "bar", cmd.CAPins[0]) - require.Equal(t, time.Minute*10, cmd.CertificateTTL) - require.Equal(t, time.Minute*5, cmd.RenewalInterval) - require.True(t, cmd.Oneshot) - require.Equal(t, "0.0.0.0:8080", cmd.DiagAddr) - require.Equal(t, "/foo", cmd.Storage) - require.Equal(t, "file:///bar", cmd.Destination) - require.Equal(t, "example.com:443", cmd.ProxyServer) - - // Convert these args to a BotConfig and check it. - cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) - 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) - 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_test.go b/lib/tbot/cli/start_kubernetes_test.go index 6f6f8527adc10..183ae805b3b0d 100644 --- a/lib/tbot/cli/start_kubernetes_test.go +++ b/lib/tbot/cli/start_kubernetes_test.go @@ -21,7 +21,6 @@ package cli import ( "testing" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/lib/tbot/config" @@ -30,46 +29,34 @@ import ( // TestKubernetesCommand tests that the KubernetesCommand properly parses its // arguments and applies as expected onto a BotConfig. func TestKubernetesCommand(t *testing.T) { - mockAction := configMutatorMock{} - mockAction.On("action", mock.Anything).Return(nil) - - app, subcommand := buildMinimalKingpinApp("start") - cmd := NewKubernetesCommand(subcommand, mockAction.action) - - // Note: various flags here are already tested as part of sharedStartArgs. - command, err := app.Parse([]string{ - "start", - "kubernetes", - "--destination=/bar", - "--token=foo", - "--join-method=github", - "--proxy-server=example.com:443", - "--kubernetes-cluster=demo", - "--disable-exec-plugin", + 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) + }, + }, }) - require.NoError(t, err) - - match, err := cmd.TryRun(command) - require.NoError(t, err) - require.True(t, match) - - mockAction.AssertCalled(t, "action", mock.Anything) - - // Convert these args to a BotConfig and check it. - cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) - require.NoError(t, err) - - 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_test.go b/lib/tbot/cli/start_legacy_test.go index f842f84904300..93a40a75b3da1 100644 --- a/lib/tbot/cli/start_legacy_test.go +++ b/lib/tbot/cli/start_legacy_test.go @@ -22,7 +22,6 @@ import ( "testing" "time" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/api/types" @@ -32,72 +31,50 @@ import ( // TestLegacyCommand tests that the LegacyCommand properly parses its arguments // and applies as expected onto a BotConfig. func TestLegacyCommand(t *testing.T) { - mockAction := configMutatorMock{} - mockAction.On("action", mock.Anything).Return(nil) - - app, subcommand := buildMinimalKingpinApp("start") - legacy := NewLegacyCommand(subcommand, mockAction.action) - - command, err := app.Parse([]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", + 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) + }, + }, }) - require.NoError(t, err) - - match, err := legacy.TryRun(command) - require.NoError(t, err) - require.True(t, match) - - mockAction.AssertCalled(t, "action", mock.Anything) - - require.Equal(t, "foo", legacy.Token) - require.Len(t, legacy.CAPins, 1) - require.Equal(t, "bar", legacy.CAPins[0]) - require.Equal(t, time.Minute*10, legacy.CertificateTTL) - require.Equal(t, time.Minute*5, legacy.RenewalInterval) - require.True(t, legacy.Oneshot) - require.Equal(t, "0.0.0.0:8080", legacy.DiagAddr) - require.Equal(t, "/foo", legacy.DataDir) - require.Equal(t, "/bar", legacy.DestinationDir) - require.Equal(t, "example.com:3024", legacy.AuthServer) - - // Convert these args to a BotConfig and check it. - cfg, err := LoadConfigWithMutators(&GlobalArgs{}, legacy) - 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) - 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_spiffe_x509_svid.go b/lib/tbot/cli/start_spiffe_svid.go similarity index 79% rename from lib/tbot/cli/start_spiffe_x509_svid.go rename to lib/tbot/cli/start_spiffe_svid.go index 237a3ff02bf0b..fd602fdb5d287 100644 --- a/lib/tbot/cli/start_spiffe_x509_svid.go +++ b/lib/tbot/cli/start_spiffe_svid.go @@ -27,9 +27,9 @@ import ( "github.com/gravitational/teleport/lib/tbot/config" ) -// SPIFFEX509SVIDCommand implements `tbot start spiffe-x509-svid` and -// `tbot configure spiffe-x509-svid`. -type SPIFFEX509SVIDCommand struct { +// SPIFFESVIDCommand implements `tbot start spiffe-svid` and +// `tbot configure spiffe-svid`. +type SPIFFESVIDCommand struct { *sharedStartArgs *genericMutatorHandler @@ -42,13 +42,13 @@ type SPIFFEX509SVIDCommand struct { IPSANs []string } -// NewSPIFFEX509SVIDCommand initializes the command and flags for the -// `spiffe-x509-svid` output and returns a struct that will contain the parse +// NewSPIFFESVIDCommand initializes the command and flags for the +// `spiffe-svid` output and returns a struct that will contain the parse // result. -func NewSPIFFEX509SVIDCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *SPIFFEX509SVIDCommand { - cmd := parentCmd.Command("spiffe-x509-svid", "Starts with a SPIFFE-compatible X509 SVID output") +func NewSPIFFESVIDCommand(parentCmd *kingpin.CmdClause, action MutatorAction) *SPIFFESVIDCommand { + cmd := parentCmd.Command("spiffe-svid", "Starts with a SPIFFE-compatible SVID output") - c := &SPIFFEX509SVIDCommand{} + c := &SPIFFESVIDCommand{} c.sharedStartArgs = newSharedStartArgs(cmd) c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action) @@ -62,7 +62,7 @@ func NewSPIFFEX509SVIDCommand(parentCmd *kingpin.CmdClause, action MutatorAction return c } -func (c *SPIFFEX509SVIDCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error { +func (c *SPIFFESVIDCommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error { if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil { return trace.Wrap(err) } diff --git a/lib/tbot/cli/start_spiffe_svid_test.go b/lib/tbot/cli/start_spiffe_svid_test.go new file mode 100644 index 0000000000000..1e39932c56296 --- /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-x509-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/cli/start_spiffe_x509_svid_test.go b/lib/tbot/cli/start_spiffe_x509_svid_test.go deleted file mode 100644 index 4f2b2ae2bb462..0000000000000 --- a/lib/tbot/cli/start_spiffe_x509_svid_test.go +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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/mock" - "github.com/stretchr/testify/require" - - "github.com/gravitational/teleport/lib/tbot/config" -) - -// TestSPIFFEX509SVIDCommand tests that the SPIFFEX509SVIDCommand properly parses its -// arguments and applies as expected onto a BotConfig. -func TestSPIFFEX509SVIDCommand(t *testing.T) { - mockAction := configMutatorMock{} - mockAction.On("action", mock.Anything).Return(nil) - - app, subcommand := buildMinimalKingpinApp("start") - cmd := NewSPIFFEX509SVIDCommand(subcommand, mockAction.action) - - // Note: various flags here are already tested as part of sharedStartArgs. - command, err := app.Parse([]string{ - "start", - "spiffe-x509-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", - }) - require.NoError(t, err) - - match, err := cmd.TryRun(command) - require.NoError(t, err) - require.True(t, match) - - mockAction.AssertCalled(t, "action", mock.Anything) - - // Convert these args to a BotConfig and check it. - cfg, err := LoadConfigWithMutators(&GlobalArgs{}, cmd) - require.NoError(t, err) - - 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/tool/tbot/main.go b/tool/tbot/main.go index 336e6046beadb..b854728e8b8d9 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -138,8 +138,8 @@ func Run(args []string, stdout io.Writer) error { cli.NewDatabaseTunnelCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), cli.NewDatabaseTunnelCommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout)), - cli.NewSPIFFEX509SVIDCommand(startCmd, buildConfigAndStart(ctx, globalCfg)), - cli.NewSPIFFEX509SVIDCommand(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 From 7ba9478b90ee5bbb4f39d39b35305ab1f14b09c4 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 9 Oct 2024 09:59:59 +0100 Subject: [PATCH 14/18] Fix command name --- lib/tbot/cli/start_spiffe_svid_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/tbot/cli/start_spiffe_svid_test.go b/lib/tbot/cli/start_spiffe_svid_test.go index 1e39932c56296..94528318f5518 100644 --- a/lib/tbot/cli/start_spiffe_svid_test.go +++ b/lib/tbot/cli/start_spiffe_svid_test.go @@ -34,7 +34,7 @@ func TestSPIFFESVIDCommand(t *testing.T) { name: "success", args: []string{ "start", - "spiffe-x509-svid", + "spiffe-svid", "--destination=/bar", "--token=foo", "--join-method=github", From 12ff2947187583b2e8bfca461e21bf312eb36b18 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 9 Oct 2024 10:08:48 +0100 Subject: [PATCH 15/18] Adjust behaviour of `tbot kube credentials` command --- lib/tbot/cli/kube_credentials.go | 1 - tool/tbot/kube.go | 11 ++--------- tool/tbot/main.go | 2 +- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/lib/tbot/cli/kube_credentials.go b/lib/tbot/cli/kube_credentials.go index 83c7134e1ddf0..1e83dda1b841a 100644 --- a/lib/tbot/cli/kube_credentials.go +++ b/lib/tbot/cli/kube_credentials.go @@ -32,7 +32,6 @@ func NewKubeCredentialsCommand(parentCmd KingpinClause, action func(*KubeCredent c := &KubeCredentialsCommand{} c.genericExecutorHandler = newGenericExecutorHandler(cmd, c, action) - // TODO: this does not appear to be used. remove it? cmd.Flag("destination-dir", "The destination directory with which to generate Kubernetes credentials").Required().StringVar(&c.DestinationDir) return c diff --git a/tool/tbot/kube.go b/tool/tbot/kube.go index 895ecd6ccaf07..2314ec83bc984 100644 --- a/tool/tbot/kube.go +++ b/tool/tbot/kube.go @@ -35,7 +35,6 @@ import ( "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" ) @@ -67,15 +66,9 @@ func getCredentialData(idFile *identityfile.IdentityFile, currentTime time.Time) } func onKubeCredentialsCommand( - ctx context.Context, globalCfg *cli.GlobalArgs, - kubeCredentialsCmd *cli.KubeCredentialsCommand, + ctx context.Context, kubeCredentialsCmd *cli.KubeCredentialsCommand, ) error { - cfg, err := cli.LoadConfigWithMutators(globalCfg) - if err != nil { - return trace.Wrap(err) - } - - destination, err := tshwrap.GetDestinationDirectory(cfg) + 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 b854728e8b8d9..459fc172d7e40 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -113,7 +113,7 @@ func Run(args []string, stdout io.Writer) error { }), cli.NewKubeCredentialsCommand(kubeCmd, func(kubeCredentialsCmd *cli.KubeCredentialsCommand) error { - return onKubeCredentialsCommand(ctx, globalCfg, kubeCredentialsCmd) + return onKubeCredentialsCommand(ctx, kubeCredentialsCmd) }), // `start` and `configure` commands From fd3417f1ac6de95c4b28837a521d3d095b1343a6 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 9 Oct 2024 11:15:01 +0100 Subject: [PATCH 16/18] Add tests for remaining commands --- lib/tbot/cli/cli_test.go | 316 +++------------------ lib/tbot/cli/db_test.go | 48 ++++ lib/tbot/cli/init_test.go | 48 ++++ lib/tbot/cli/kube_credentials_test.go | 40 +++ lib/tbot/cli/migrate_test.go | 40 +++ lib/tbot/cli/proxy.go | 4 +- lib/tbot/cli/proxy_test.go | 48 ++++ lib/tbot/cli/ssh_multiplexer_proxy.go | 10 +- lib/tbot/cli/ssh_multiplexer_proxy_test.go | 42 +++ lib/tbot/cli/ssh_proxy.go | 4 +- lib/tbot/cli/ssh_proxy_test.go | 56 ++++ tool/tbot/main.go | 2 +- 12 files changed, 371 insertions(+), 287 deletions(-) create mode 100644 lib/tbot/cli/db_test.go create mode 100644 lib/tbot/cli/init_test.go create mode 100644 lib/tbot/cli/kube_credentials_test.go create mode 100644 lib/tbot/cli/migrate_test.go create mode 100644 lib/tbot/cli/proxy_test.go create mode 100644 lib/tbot/cli/ssh_multiplexer_proxy_test.go create mode 100644 lib/tbot/cli/ssh_proxy_test.go diff --git a/lib/tbot/cli/cli_test.go b/lib/tbot/cli/cli_test.go index b79ba564477a2..64ce04a6705fd 100644 --- a/lib/tbot/cli/cli_test.go +++ b/lib/tbot/cli/cli_test.go @@ -19,12 +19,10 @@ package cli import ( - "fmt" "log/slog" "testing" "github.com/alecthomas/kingpin/v2" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/gravitational/teleport/api/types" @@ -32,15 +30,6 @@ import ( "github.com/gravitational/teleport/lib/utils" ) -type genericExecutorMock[T any] struct { - mock.Mock -} - -func (m *genericExecutorMock[T]) action(cmd *T) error { - args := m.Called(cmd) - return args.Error(0) -} - func buildMinimalKingpinApp(subcommandName string) (app *kingpin.Application, subcommand *kingpin.CmdClause) { app = utils.InitCLIParser("tbot", "test").Interspersed(false) subcommand = app.Command(subcommandName, "subcommand") @@ -48,269 +37,6 @@ func buildMinimalKingpinApp(subcommandName string) (app *kingpin.Application, su return } -// TestConfigMutators that all config mutator-style match on their expected -// CLI args and return appropriately-typed parse results. This does not validate -// that the resulting configuration is valid (i.e. may not successfully pass -// conversion to BotConfig and the associated CheckAndSetDefaults()) -func TestConfigMutators(t *testing.T) { - tests := []struct { - name string - args [][]string - buildCommand func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner - assert func(t *testing.T, value any) - }{ - { - name: "legacy", - args: [][]string{{}, {"legacy"}}, - buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { - return NewLegacyCommand(parent, callback) - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &LegacyCommand{}, value) - }, - }, - { - name: "identity", - args: [][]string{ - {"identity", "--destination=foo"}, - {"id", "--destination=foo"}, - {"ssh", "--destination=foo"}, - }, - buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { - return NewIdentityCommand(parent, callback) - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &IdentityCommand{}, value) - }, - }, - - { - name: "database", - args: [][]string{ - {"database", "--destination=foo", "--service=foo", "--username=bar", "--database=baz"}, - {"db", "--destination=foo", "--service=foo", "--username=bar", "--database=baz"}, - }, - buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { - return NewDatabaseCommand(parent, callback) - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &DatabaseCommand{}, value) - }, - }, - { - name: "kubernetes", - args: [][]string{ - {"kubernetes", "--destination=foo", "--kubernetes-cluster=foo"}, - {"k8s", "--destination=foo", "--kubernetes-cluster=foo"}, - }, - buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { - return NewKubernetesCommand(parent, callback) - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &KubernetesCommand{}, value) - }, - }, - { - name: "application", - args: [][]string{ - {"application", "--destination=foo", "--app=foo"}, - {"app", "--destination=foo", "--app=foo"}, - }, - buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { - return NewApplicationCommand(parent, callback) - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &ApplicationCommand{}, value) - }, - }, - { - name: "spiffe-x509-svid", - args: [][]string{ - {"spiffe-x509-svid", "--destination=foo", "--svid-path=/bar"}, - }, - buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { - return NewSPIFFESVIDCommand(parent, callback) - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &SPIFFESVIDCommand{}, value) - }, - }, - { - name: "application-tunnel", - args: [][]string{ - {"application-tunnel", "--app=foo", "--listen=tcp://0.0.0.0:8080"}, - {"app-tunnel", "--app=foo", "--listen=tcp://0.0.0.0:8080"}, - }, - buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { - return NewApplicationTunnelCommand(parent, callback) - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &ApplicationTunnelCommand{}, value) - }, - }, - { - name: "database-tunnel", - args: [][]string{ - {"database-tunnel", "--service=foo", "--username=bar", "--database=baz", "--listen=tcp://0.0.0.0:8080"}, - {"db-tunnel", "--service=foo", "--username=bar", "--database=baz", "--listen=tcp://0.0.0.0:8080"}, - }, - buildCommand: func(parent *kingpin.CmdClause, callback MutatorAction) CommandRunner { - return NewDatabaseTunnelCommand(parent, callback) - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &DatabaseTunnelCommand{}, value) - }, - }, - } - - for _, tt := range tests { - tt := tt - - t.Run(tt.name, func(t *testing.T) { - for i, argSet := range tt.args { - argSet := argSet - - t.Run(fmt.Sprint(i), func(t *testing.T) { - subcommandName := "sub" - app, subcommand := buildMinimalKingpinApp(subcommandName) - - actionCalled := false - var actionCalledMutator ConfigMutator - runner := tt.buildCommand(subcommand, func(mutator ConfigMutator) error { - actionCalled = true - actionCalledMutator = mutator - return nil - }) - - command, err := app.Parse(append([]string{subcommandName}, argSet...)) - require.NoError(t, err) - - match, err := runner.TryRun(command) - require.NoError(t, err) - require.True(t, match) - - require.True(t, actionCalled) - - tt.assert(t, actionCalledMutator) - }) - } - }) - } -} - -func TestExecutors(t *testing.T) { - // Note: Currently all executor-style - tests := []struct { - name string - args []string - buildCommand func(app *kingpin.Application) (CommandRunner, *mock.Mock) - assert func(t *testing.T, value any) - }{ - { - name: "init", - args: []string{"init"}, - buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { - m := &genericExecutorMock[InitCommand]{} - return NewInitCommand(app, m.action), &m.Mock - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &InitCommand{}, value) - }, - }, - { - name: "db", - args: []string{"db"}, - buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { - m := &genericExecutorMock[DBCommand]{} - return NewDBCommand(app, m.action), &m.Mock - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &DBCommand{}, value) - }, - }, - { - // note: this expects to be mounted to a "kube" parent command. for - // the test, we'll just mount it to the application. - name: "kube credentials", - args: []string{"credentials", "--destination-dir=foo"}, - buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { - m := &genericExecutorMock[KubeCredentialsCommand]{} - return NewKubeCredentialsCommand(app, m.action), &m.Mock - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &KubeCredentialsCommand{}, value) - }, - }, - { - name: "migrate", - args: []string{"migrate"}, - buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { - m := &genericExecutorMock[MigrateCommand]{} - return NewMigrateCommand(app, m.action), &m.Mock - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &MigrateCommand{}, value) - }, - }, - { - name: "proxy", - args: []string{"proxy"}, - buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { - m := &genericExecutorMock[ProxyCommand]{} - return NewProxyCommand(app, m.action), &m.Mock - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &ProxyCommand{}, value) - }, - }, - { - name: "ssh-multiplexer-proxy-command", - args: []string{"ssh-multiplexer-proxy-command", "/foo", "bar"}, - buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { - m := &genericExecutorMock[SSHMultiplerProxyCommand]{} - return NewSSHMultiplexerProxyCommand(app, m.action), &m.Mock - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &SSHMultiplerProxyCommand{}, value) - }, - }, - { - name: "ssh-proxy-command", - args: []string{"ssh-proxy-command", "--user=foo", "--host=bar", "--proxy-server=baz", "--tls-routing", "--connection-upgrade"}, - buildCommand: func(app *kingpin.Application) (CommandRunner, *mock.Mock) { - m := &genericExecutorMock[SSHProxyCommand]{} - return NewSSHProxyCommand(app, m.action), &m.Mock - }, - assert: func(t *testing.T, value any) { - require.IsType(t, &SSHProxyCommand{}, value) - }, - }, - } - - for _, tt := range tests { - tt := tt - - t.Run(tt.name, func(t *testing.T) { - app, _ := buildMinimalKingpinApp("sub") - - runner, mockAction := tt.buildCommand(app) - mockAction.On("action", mock.Anything).Return(nil) - - command, err := app.Parse(tt.args) - require.NoError(t, err) - - match, err := runner.TryRun(command) - require.NoError(t, err) - require.True(t, match) - - mockAction.AssertCalled(t, "action", mock.Anything) - - arg := mockAction.Calls[0].Arguments.Get(0) - tt.assert(t, arg) - }) - } -} - func TestConfigCLIOnlySample(t *testing.T) { // Test the sample config generated by `tctl bots add ...` legacy := &LegacyCommand{ @@ -359,9 +85,49 @@ func TestConfigCLIOnlySample(t *testing.T) { 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 - TryRun(cmd string) (match bool, err error) + basicCommand } type startConfigureTestCase struct { diff --git a/lib/tbot/cli/db_test.go b/lib/tbot/cli/db_test.go new file mode 100644 index 0000000000000..641acc87fe422 --- /dev/null +++ b/lib/tbot/cli/db_test.go @@ -0,0 +1,48 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDBCommand(t *testing.T) { + testCommand(t, NewDBCommand, []testCommandCase[*DBCommand]{ + { + name: "success", + args: []string{ + "db", + "--proxy-server=example.com:22", + "--destination-dir=/tmp", + "--cluster=example.com", + "bar", + "buzz", + "boo", + }, + assert: func(t *testing.T, got *DBCommand) { + require.Equal(t, "example.com:22", got.ProxyServer) + require.Equal(t, "/tmp", got.DestinationDir) + require.Equal(t, "example.com", got.Cluster) + require.Equal(t, []string{"bar", "buzz", "boo"}, *got.RemainingArgs) + }, + }, + }) +} diff --git a/lib/tbot/cli/init_test.go b/lib/tbot/cli/init_test.go new file mode 100644 index 0000000000000..86218a3a16070 --- /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.Equal(t, true, got.Clean) + }, + }, + }) +} diff --git a/lib/tbot/cli/kube_credentials_test.go b/lib/tbot/cli/kube_credentials_test.go new file mode 100644 index 0000000000000..ed14feeb432ee --- /dev/null +++ b/lib/tbot/cli/kube_credentials_test.go @@ -0,0 +1,40 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestKubeCredentialsCommand(t *testing.T) { + testCommand(t, NewKubeCredentialsCommand, []testCommandCase[*KubeCredentialsCommand]{ + { + name: "success", + args: []string{ + "credentials", + "--destination-dir=/tmp", + }, + assert: func(t *testing.T, got *KubeCredentialsCommand) { + require.Equal(t, "/tmp", got.DestinationDir) + }, + }, + }) +} diff --git a/lib/tbot/cli/migrate_test.go b/lib/tbot/cli/migrate_test.go new file mode 100644 index 0000000000000..ea3d59546718f --- /dev/null +++ b/lib/tbot/cli/migrate_test.go @@ -0,0 +1,40 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMigrateCommand(t *testing.T) { + testCommand(t, NewMigrateCommand, []testCommandCase[*MigrateCommand]{ + { + name: "success", + args: []string{ + "migrate", + "--output=/tmp", + }, + assert: func(t *testing.T, got *MigrateCommand) { + require.Equal(t, "/tmp", got.ConfigureOutput) + }, + }, + }) +} diff --git a/lib/tbot/cli/proxy.go b/lib/tbot/cli/proxy.go index 61d383b580855..556f793016bee 100644 --- a/lib/tbot/cli/proxy.go +++ b/lib/tbot/cli/proxy.go @@ -20,8 +20,6 @@ package cli import ( "context" - - "github.com/alecthomas/kingpin/v2" ) // ProxyCommand supports `tbot proxy` @@ -41,7 +39,7 @@ type ProxyCommand struct { } // NewProxyCommand initializes the subcommand for `tbot proxy` -func NewProxyCommand(app *kingpin.Application, action func(*ProxyCommand) error) *ProxyCommand { +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{} diff --git a/lib/tbot/cli/proxy_test.go b/lib/tbot/cli/proxy_test.go new file mode 100644 index 0000000000000..bcd65b99f113a --- /dev/null +++ b/lib/tbot/cli/proxy_test.go @@ -0,0 +1,48 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestProxyCommand(t *testing.T) { + testCommand(t, NewProxyCommand, []testCommandCase[*ProxyCommand]{ + { + name: "success", + args: []string{ + "proxy", + "--proxy-server=example.com:22", + "--destination-dir=/tmp", + "--cluster=example.com", + "bar", + "buzz", + "boo", + }, + assert: func(t *testing.T, got *ProxyCommand) { + require.Equal(t, "example.com:22", got.ProxyServer) + require.Equal(t, "/tmp", got.DestinationDir) + require.Equal(t, "example.com", got.Cluster) + require.Equal(t, []string{"bar", "buzz", "boo"}, *got.ProxyRemaining) + }, + }, + }) +} diff --git a/lib/tbot/cli/ssh_multiplexer_proxy.go b/lib/tbot/cli/ssh_multiplexer_proxy.go index 5dec0fc17aa92..d3757b59437e8 100644 --- a/lib/tbot/cli/ssh_multiplexer_proxy.go +++ b/lib/tbot/cli/ssh_multiplexer_proxy.go @@ -18,22 +18,22 @@ package cli -// SSHMultiplerProxyCommand includes fields for `tbot ssh-multiplexer-proxy-command` -type SSHMultiplerProxyCommand struct { - *genericExecutorHandler[SSHMultiplerProxyCommand] +// 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(*SSHMultiplerProxyCommand) error) *SSHMultiplerProxyCommand { +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 := &SSHMultiplerProxyCommand{} + c := &SSHMultiplexerProxyCommand{} c.genericExecutorHandler = newGenericExecutorHandler(cmd, c, action) cmd.Arg("path", "Path to the listener socket.").Required().StringVar(&c.Socket) diff --git a/lib/tbot/cli/ssh_multiplexer_proxy_test.go b/lib/tbot/cli/ssh_multiplexer_proxy_test.go new file mode 100644 index 0000000000000..8179bc7f346d1 --- /dev/null +++ b/lib/tbot/cli/ssh_multiplexer_proxy_test.go @@ -0,0 +1,42 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSSHMultiplexerProxyCommand(t *testing.T) { + testCommand(t, NewSSHMultiplexerProxyCommand, []testCommandCase[*SSHMultiplexerProxyCommand]{ + { + name: "success", + args: []string{ + "ssh-multiplexer-proxy-command", + "/tmp/sock", + "example.com:22", + }, + assert: func(t *testing.T, got *SSHMultiplexerProxyCommand) { + require.Equal(t, "/tmp/sock", got.Socket) + require.Equal(t, "example.com:22", got.Data) + }, + }, + }) +} diff --git a/lib/tbot/cli/ssh_proxy.go b/lib/tbot/cli/ssh_proxy.go index 7139653db4ee1..2db0bc2ee32e9 100644 --- a/lib/tbot/cli/ssh_proxy.go +++ b/lib/tbot/cli/ssh_proxy.go @@ -18,8 +18,6 @@ package cli -import "github.com/alecthomas/kingpin/v2" - // SSHProxyCommand includes fields for `tbot ssh-proxy-command` type SSHProxyCommand struct { *genericExecutorHandler[SSHProxyCommand] @@ -62,7 +60,7 @@ type SSHProxyCommand struct { // NewSSHProxyCommand initializes the `tbot ssh-proxy-command` subcommand and // its fields. -func NewSSHProxyCommand(app *kingpin.Application, action func(*SSHProxyCommand) error) *SSHProxyCommand { +func NewSSHProxyCommand(app KingpinClause, action func(*SSHProxyCommand) error) *SSHProxyCommand { cmd := app.Command("ssh-proxy-command", "An OpenSSH/PuTTY proxy command").Hidden() c := &SSHProxyCommand{} diff --git a/lib/tbot/cli/ssh_proxy_test.go b/lib/tbot/cli/ssh_proxy_test.go new file mode 100644 index 0000000000000..9818c3b52f6a2 --- /dev/null +++ b/lib/tbot/cli/ssh_proxy_test.go @@ -0,0 +1,56 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSSHProxyCommand(t *testing.T) { + testCommand(t, NewSSHProxyCommand, []testCommandCase[*SSHProxyCommand]{ + { + name: "success", + args: []string{ + "ssh-proxy-command", + "--destination-dir=/bar", + "--cluster=foo", + "--user=noah", + "--host=example.com", + "--proxy-server=example.com:443", + "--tls-routing", + "--connection-upgrade", + "--proxy-templates=/tmp/tsh.yaml", + "--resume", + }, + assert: func(t *testing.T, got *SSHProxyCommand) { + require.Equal(t, "/bar", got.DestinationDir) + require.Equal(t, "foo", got.Cluster) + require.Equal(t, "noah", got.User) + require.Equal(t, "example.com", got.Host) + require.Equal(t, "example.com:443", got.ProxyServer) + require.True(t, got.TLSRoutingEnabled) + require.True(t, got.ConnectionUpgradeRequired) + require.Equal(t, "/tmp/tsh.yaml", got.TSHConfigPath) + require.True(t, got.EnableResumption) + }, + }, + }) +} diff --git a/tool/tbot/main.go b/tool/tbot/main.go index 459fc172d7e40..b73fc6c2c2b0e 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -108,7 +108,7 @@ func Run(args []string, stdout io.Writer) error { return onDBCommand(globalCfg, dbCmd) }), - cli.NewSSHMultiplexerProxyCommand(app, func(c *cli.SSHMultiplerProxyCommand) error { + cli.NewSSHMultiplexerProxyCommand(app, func(c *cli.SSHMultiplexerProxyCommand) error { return onSSHMultiplexProxyCommand(ctx, c.Socket, c.Data) }), From 62240ac531d954260ea9f6a47453652d48b502de Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 9 Oct 2024 11:26:55 +0100 Subject: [PATCH 17/18] Appease linter --- lib/tbot/cli/init_test.go | 2 +- lib/tbot/cli/start_application.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/tbot/cli/init_test.go b/lib/tbot/cli/init_test.go index 86218a3a16070..af37c957bed1d 100644 --- a/lib/tbot/cli/init_test.go +++ b/lib/tbot/cli/init_test.go @@ -41,7 +41,7 @@ func TestInitCommand(t *testing.T) { require.Equal(t, "jeffrey", got.BotUser) require.Equal(t, "bob", got.ReaderUser) require.Equal(t, "/tmp", got.InitDir) - require.Equal(t, true, got.Clean) + require.True(t, got.Clean) }, }, }) diff --git a/lib/tbot/cli/start_application.go b/lib/tbot/cli/start_application.go index b5c684c6beda5..2d3665cdd25b9 100644 --- a/lib/tbot/cli/start_application.go +++ b/lib/tbot/cli/start_application.go @@ -38,7 +38,7 @@ type ApplicationCommand struct { SpecificTLSExtensions bool } -// NewApplicationCommand initalizes a command and flag for application outputs +// 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") From 64fc6f7953c24bfaa9dbeec22088d288f8491c62 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Thu, 10 Oct 2024 09:09:42 +0100 Subject: [PATCH 18/18] Add godocs --- lib/tbot/cli/cli.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/tbot/cli/cli.go b/lib/tbot/cli/cli.go index 3159ac9a9b2ef..ff820fc0d0f89 100644 --- a/lib/tbot/cli/cli.go +++ b/lib/tbot/cli/cli.go @@ -31,8 +31,14 @@ import ( ) const ( - AuthServerEnvVar = "TELEPORT_AUTH_SERVER" - TokenEnvVar = "TELEPORT_BOT_TOKEN" + // 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" )