diff --git a/tool/tbot/config/config.go b/tool/tbot/config/config.go index fea9f0166aa52..f0bbb0129d393 100644 --- a/tool/tbot/config/config.go +++ b/tool/tbot/config/config.go @@ -61,9 +61,9 @@ type CLIConf struct { // Token is a bot join token. Token string - // RenewInterval is the interval at which certificates are renewed, as a + // RenewalInterval is the interval at which certificates are renewed, as a // time.ParseDuration() string. It must be less than the certificate TTL. - RenewInterval time.Duration + RenewalInterval time.Duration // CertificateTTL is the requested TTL of certificates. It should be some // multiple of the renewal interval to allow for failed renewals. @@ -73,6 +73,9 @@ type CLIConf struct { // 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 @@ -117,17 +120,14 @@ type BotConfig struct { Storage *StorageConfig `yaml:"storage,omitempty"` Destinations []*DestinationConfig `yaml:"destinations,omitempty"` - Debug bool `yaml:"debug"` - AuthServer string `yaml:"auth_server"` - CertificateTTL time.Duration `yaml:"certificate_ttl"` - RenewInterval time.Duration `yaml:"renew_interval"` + Debug bool `yaml:"debug"` + AuthServer string `yaml:"auth_server"` + CertificateTTL time.Duration `yaml:"certificate_ttl"` + RenewalInterval time.Duration `yaml:"renewal_interval"` + Oneshot bool `yaml:"oneshot"` } func (conf *BotConfig) CheckAndSetDefaults() error { - if conf.AuthServer == "" { - return trace.BadParameter("an auth server address must be configured") - } - if conf.Storage == nil { conf.Storage = &StorageConfig{} } @@ -146,8 +146,8 @@ func (conf *BotConfig) CheckAndSetDefaults() error { conf.CertificateTTL = DefaultCertificateTTL } - if conf.RenewInterval == 0 { - conf.RenewInterval = DefaultRenewInterval + if conf.RenewalInterval == 0 { + conf.RenewalInterval = DefaultRenewInterval } return nil @@ -214,6 +214,10 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { config.Debug = true } + if cf.Oneshot { + config.Oneshot = true + } + if cf.AuthServer != "" { if config.AuthServer != "" { log.Warnf("CLI parameters are overriding auth server configured in %s", cf.ConfigPath) @@ -228,11 +232,11 @@ func FromCLIConf(cf *CLIConf) (*BotConfig, error) { config.CertificateTTL = cf.CertificateTTL } - if cf.RenewInterval != 0 { - if config.RenewInterval != 0 { + if cf.RenewalInterval != 0 { + if config.RenewalInterval != 0 { log.Warnf("CLI parameters are overriding renewal interval configured in %s", cf.ConfigPath) } - config.RenewInterval = cf.RenewInterval + config.RenewalInterval = cf.RenewalInterval } // DataDir overrides any previously-configured storage config diff --git a/tool/tbot/config/config_test.go b/tool/tbot/config/config_test.go index c7184dca96eca..9d583a480ed1f 100644 --- a/tool/tbot/config/config_test.go +++ b/tool/tbot/config/config_test.go @@ -21,6 +21,7 @@ import ( "testing" "time" + "github.com/coreos/go-semver/semver" "github.com/gravitational/teleport/tool/tbot/identity" "github.com/stretchr/testify/require" ) @@ -30,7 +31,7 @@ func TestConfigDefaults(t *testing.T) { require.NoError(t, err) require.Equal(t, DefaultCertificateTTL, cfg.CertificateTTL) - require.Equal(t, DefaultRenewInterval, cfg.RenewInterval) + require.Equal(t, DefaultRenewInterval, cfg.RenewalInterval) storageDest, err := cfg.Storage.GetDestination() require.NoError(t, err) @@ -93,7 +94,7 @@ func TestConfigFile(t *testing.T) { require.NoError(t, err) require.Equal(t, "auth.example.com", cfg.AuthServer) - require.Equal(t, time.Minute*5, cfg.RenewInterval) + require.Equal(t, time.Minute*5, cfg.RenewalInterval) require.NotNil(t, cfg.Onboarding) require.Equal(t, "foo", cfg.Onboarding.Token) @@ -125,9 +126,53 @@ func TestConfigFile(t *testing.T) { require.Equal(t, "/tmp/foo", destImplReal.Path) } +func TestParseSSHVersion(t *testing.T) { + tests := []struct { + str string + version *semver.Version + err bool + }{ + { + str: "OpenSSH_8.2p1 Ubuntu-4ubuntu0.4, OpenSSL 1.1.1f 31 Mar 2020", + version: semver.New("8.2.1"), + }, + { + str: "OpenSSH_8.8p1, OpenSSL 1.1.1m 14 Dec 2021", + version: semver.New("8.8.1"), + }, + { + str: "OpenSSH_7.5p1, OpenSSL 1.0.2s-freebsd 28 May 2019", + version: semver.New("7.5.1"), + }, + { + str: "OpenSSH_7.9p1 Raspbian-10+deb10u2, OpenSSL 1.1.1d 10 Sep 2019", + version: semver.New("7.9.1"), + }, + { + // Couldn't find a full example but in theory patch is optional: + str: "OpenSSH_8.1 foo", + version: semver.New("8.1.0"), + }, + { + str: "Teleport v8.0.0-dev.40 git:v8.0.0-dev.40-0-ge9194c256 go1.17.2", + err: true, + }, + } + + for _, test := range tests { + version, err := parseSSHVersion(test.str) + if test.err { + require.Error(t, err) + } else { + require.NoError(t, err) + require.True(t, version.Equal(*test.version), "got version = %v, want = %v", version, test.version) + } + } +} + const exampleConfigFile = ` auth_server: auth.example.com -renew_interval: 5m +renewal_interval: 5m onboarding: token: foo ca_pins: diff --git a/tool/tbot/config/configtemplate_ssh.go b/tool/tbot/config/configtemplate_ssh.go index c464c5656de90..2c8f7da6d9550 100644 --- a/tool/tbot/config/configtemplate_ssh.go +++ b/tool/tbot/config/configtemplate_ssh.go @@ -17,14 +17,18 @@ limitations under the License. package config import ( + "bytes" "context" "fmt" "os" + "os/exec" "path/filepath" + "regexp" "strconv" "strings" "text/template" + "github.com/coreos/go-semver/semver" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/defaults" @@ -40,6 +44,66 @@ type TemplateSSHClient struct { ProxyPort uint16 `yaml:"proxy_port"` } +// openSSHVersionRegex is a regex used to parse OpenSSH version strings. +var openSSHVersionRegex = regexp.MustCompile(`^OpenSSH_(?P\d+)\.(?P\d+)(?:p(?P\d+))?`) + +// openSSHMinVersionForRSAWorkaround is the OpenSSH version after which the +// RSA deprecation workaround should be added to generated ssh_config. +var openSSHMinVersionForRSAWorkaround = semver.New("8.5.0") + +// parseSSHVersion attempts to parse +func parseSSHVersion(versionString string) (*semver.Version, error) { + versionTokens := strings.Split(versionString, " ") + if len(versionTokens) == 0 { + return nil, trace.BadParameter("invalid version string: %s", versionString) + } + + versionID := versionTokens[0] + matches := openSSHVersionRegex.FindStringSubmatch(versionID) + if matches == nil { + return nil, trace.BadParameter("cannot parse version string: %q", versionID) + } + + major, err := strconv.Atoi(matches[1]) + if err != nil { + return nil, trace.Wrap(err, "invalid major version number: %s", matches[1]) + } + + minor, err := strconv.Atoi(matches[2]) + if err != nil { + return nil, trace.Wrap(err, "invalid minor version number: %s", matches[2]) + } + + patch := 0 + if matches[3] != "" { + patch, err = strconv.Atoi(matches[3]) + if err != nil { + return nil, trace.Wrap(err, "invalid patch version number: %s", matches[3]) + } + } + + return &semver.Version{ + Major: int64(major), + Minor: int64(minor), + Patch: int64(patch), + }, nil +} + +// getSSHVersion attempts to query the system SSH for its current version. +func getSSHVersion() (*semver.Version, error) { + var out bytes.Buffer + + cmd := exec.Command("ssh", "-V") + cmd.Stderr = &out + + err := cmd.Run() + if err != nil { + return nil, trace.Wrap(err) + } + + return parseSSHVersion(out.String()) +} + func (c *TemplateSSHClient) CheckAndSetDefaults() error { if c.ProxyPort == 0 { c.ProxyPort = defaults.SSHProxyListenPort @@ -111,18 +175,31 @@ func (c *TemplateSSHClient) Render(ctx context.Context, authClient auth.ClientI, return trace.Wrap(err) } + // Default to including the RSA deprecation workaround. + rsaWorkaround := true + version, err := getSSHVersion() + if err != nil { + log.WithError(err).Debugf("Could not determine SSH version, will include RSA workaround.") + } else if version.LessThan(*openSSHMinVersionForRSAWorkaround) { + log.Debugf("OpenSSH version %s does not require workaround for RSA deprecation", version) + rsaWorkaround = false + } else { + log.Debugf("OpenSSH version %s will use workaround for RSA deprecation", version) + } + var sshConfigBuilder strings.Builder identityFilePath := filepath.Join(dataDir, identity.PrivateKeyKey) certificateFilePath := filepath.Join(dataDir, identity.SSHCertKey) sshConfigPath := filepath.Join(dataDir, "ssh_config") if err := sshConfigTemplate.Execute(&sshConfigBuilder, sshConfigParameters{ - ClusterName: clusterName.GetClusterName(), - ProxyHost: proxyHost, - ProxyPort: proxyPort, - KnownHostsPath: knownHostsPath, - IdentityFilePath: identityFilePath, - CertificateFilePath: certificateFilePath, - SSHConfigPath: sshConfigPath, + ClusterName: clusterName.GetClusterName(), + ProxyHost: proxyHost, + ProxyPort: proxyPort, + KnownHostsPath: knownHostsPath, + IdentityFilePath: identityFilePath, + CertificateFilePath: certificateFilePath, + SSHConfigPath: sshConfigPath, + IncludeRSAWorkaround: rsaWorkaround, }); err != nil { return trace.Wrap(err) } @@ -142,6 +219,15 @@ type sshConfigParameters struct { ProxyHost string ProxyPort string SSHConfigPath string + + // IncludeRSAWorkaround controls whether the RSA deprecation workaround is + // included in the generated configuration. Newer versions of OpenSSH + // deprecate RSA certificates and, due to a bug in golang's ssh package, + // Teleport wrongly advertises its unaffected certificates as a + // now-deprecated certificate type. The workaround includes a config + // override to re-enable RSA certs for just Teleport hosts, however it is + // only supported on OpenSSH 8.5 and later. + IncludeRSAWorkaround bool } var sshConfigTemplate = template.Must(template.New("ssh-config").Parse(` @@ -152,13 +238,13 @@ Host *.{{ .ClusterName }} {{ .ProxyHost }} UserKnownHostsFile "{{ .KnownHostsPath }}" IdentityFile "{{ .IdentityFilePath }}" CertificateFile "{{ .CertificateFilePath }}" - HostKeyAlgorithms ssh-rsa-cert-v01@openssh.com - PubkeyAcceptedAlgorithms +ssh-rsa-cert-v01@openssh.com + HostKeyAlgorithms ssh-rsa-cert-v01@openssh.com{{- if .IncludeRSAWorkaround }} + PubkeyAcceptedAlgorithms +ssh-rsa-cert-v01@openssh.com{{- end }} # Flags for all {{ .ClusterName }} hosts except the proxy Host *.{{ .ClusterName }} !{{ .ProxyHost }} Port 3022 - ProxyCommand ssh -F {{ .SSHConfigPath }} -l %r -p {{ .ProxyPort }} {{ .ProxyHost }} -s proxy:%h:%p@{{ .ClusterName }} + ProxyCommand ssh -F {{ .SSHConfigPath }} -l %r -p {{ .ProxyPort }} {{ .ProxyHost }} -s proxy:$(echo %h | cut -d '.' -f 1):%p@{{ .ClusterName }} # End generated Teleport configuration `)) diff --git a/tool/tbot/main.go b/tool/tbot/main.go index ab57cb849be8d..f73cb11c6c2d8 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -23,7 +23,9 @@ import ( "encoding/hex" "fmt" "os" + "os/signal" "strings" + "syscall" "time" "github.com/gravitational/teleport" @@ -66,10 +68,12 @@ func Run(args []string) error { var cf config.CLIConf utils.InitLogger(utils.LoggingForDaemon, logrus.InfoLevel) - app := utils.InitCLIParser("tbot", "tbot: Teleport Credential Bot").Interspersed(false) + app := utils.InitCLIParser("tbot", "tbot: Teleport Machine ID").Interspersed(false) app.Flag("debug", "Verbose logging to stdout").Short('d').BoolVar(&cf.Debug) app.Flag("config", "tbot.yaml path").Short('c').StringVar(&cf.ConfigPath) + versionCmd := app.Command("version", "Print the version") + startCmd := app.Command("start", "Starts the renewal bot, writing certificates to the data dir at a set interval.") startCmd.Flag("auth-server", "Specify the Teleport auth server host").Short('a').Envar(authServerEnvVar).StringVar(&cf.AuthServer) startCmd.Flag("token", "A bot join token, if attempting to onboard a new bot; used on first connect.").Envar(tokenEnvVar).StringVar(&cf.Token) @@ -77,11 +81,11 @@ func Run(args []string) error { startCmd.Flag("data-dir", "Directory to store internal bot data.").StringVar(&cf.DataDir) startCmd.Flag("destination-dir", "Directory to write generated certificates").StringVar(&cf.DestinationDir) startCmd.Flag("certificate-ttl", "TTL of generated certificates").Default("60m").DurationVar(&cf.CertificateTTL) - startCmd.Flag("renew-interval", "Interval at which certificates are renewed; must be less than the certificate TTL.").DurationVar(&cf.RenewInterval) + startCmd.Flag("renewal-interval", "Interval at which certificates are renewed; must be less than the certificate TTL.").DurationVar(&cf.RenewalInterval) startCmd.Flag("join-method", "Method to use to join the cluster.").Default(config.DefaultJoinMethod).EnumVar(&cf.JoinMethod, "token", "iam") + startCmd.Flag("oneshot", "If set, quit after the first renewal.").BoolVar(&cf.Oneshot) initCmd := app.Command("init", "Initialize a certificate destination directory for writes from a separate bot user.") - initCmd.Flag("auth-server", "Specify the Teleport auth server host").Short('a').Envar(authServerEnvVar).StringVar(&cf.AuthServer) initCmd.Flag("destination-dir", "If NOT using a config file, specify the destination directory.").StringVar(&cf.DestinationDir) initCmd.Flag("init-dir", "If using a config file and multiple destinations are configured, specify which to initialize.").StringVar(&cf.InitDir) initCmd.Flag("clean", "If set, remove unexpected files and directories from the destination.").BoolVar(&cf.Clean) @@ -117,6 +121,8 @@ func Run(args []string) error { } switch command { + case versionCmd.FullCommand(): + err = onVersion() case startCmd.FullCommand(): err = onStart(botConfig) case configCmd.FullCommand(): @@ -133,6 +139,11 @@ func Run(args []string) error { return err } +func onVersion() error { + utils.PrintVersion() + return nil +} + func onConfig(botConfig *config.BotConfig) error { pretty.Println(botConfig) @@ -144,6 +155,10 @@ func onWatch(botConfig *config.BotConfig) error { } func onStart(botConfig *config.BotConfig) error { + if botConfig.AuthServer == "" { + return trace.BadParameter("an auth or proxy server must be set via --auth-server or configuration") + } + // First, try to make sure all destinations are usable. if err := checkDestinations(botConfig); err != nil { return trace.Wrap(err) @@ -155,18 +170,20 @@ func onStart(botConfig *config.BotConfig) error { return trace.Wrap(err, "could not read bot storage destination from config") } - var authClient auth.ClientI - - // TODO: graceful shutdown via signal; see #7066 + reloadChan := make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() + go handleSignals(reloadChan, cancel) + configTokenHashBytes := []byte{} if botConfig.Onboarding != nil && botConfig.Onboarding.Token != "" { sha := sha256.Sum256([]byte(botConfig.Onboarding.Token)) configTokenHashBytes = []byte(hex.EncodeToString(sha[:])) } + var authClient auth.ClientI + // First, attempt to load an identity from storage. ident, err := identity.LoadIdentity(dest, identity.BotKinds()...) if err == nil && !hasTokenChanged(ident.TokenHashBytes, configTokenHashBytes) { @@ -248,7 +265,7 @@ func onStart(botConfig *config.BotConfig) error { defer watcher.Close() - return renewLoop(ctx, botConfig, authClient, ident) + return renewLoop(ctx, botConfig, authClient, ident, reloadChan) } func hasTokenChanged(configTokenBytes, identityBytes []byte) bool { @@ -326,6 +343,24 @@ func checkIdentity(ident *identity.Identity) error { return nil } +// handleSignals handles incoming Unix signals. +func handleSignals(reload chan struct{}, cancel context.CancelFunc) { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGHUP, syscall.SIGUSR1) + + for signal := range signals { + switch signal { + case syscall.SIGINT: + log.Info("Received interrupt, cancelling...") + cancel() + return + case syscall.SIGHUP, syscall.SIGUSR1: + log.Info("Received reload signal, reloading...") + reload <- struct{}{} + } + } +} + func watchCARotations(watcher types.Watcher) { for { select { @@ -646,11 +681,11 @@ func renew( } } - log.Infof("Persisted new certificates to disk. Next renewal in approximately %s", cfg.RenewInterval) + log.Infof("Persisted new certificates to disk. Next renewal in approximately %s", cfg.RenewalInterval) return newClient, newIdentity, nil } -func renewLoop(ctx context.Context, cfg *config.BotConfig, client auth.ClientI, ident *identity.Identity) error { +func renewLoop(ctx context.Context, cfg *config.BotConfig, client auth.ClientI, ident *identity.Identity, reloadChan chan struct{}) error { // TODO: failures here should probably not just end the renewal loop, there // should be some retry / back-off logic. @@ -658,12 +693,12 @@ func renewLoop(ctx context.Context, cfg *config.BotConfig, client auth.ClientI, // Also, must be < the validity period. // TODO: validate that cert is actually renewable. - log.Infof("Beginning renewal loop: ttl=%s interval=%s", cfg.CertificateTTL, cfg.RenewInterval) - if cfg.RenewInterval > cfg.CertificateTTL { + log.Infof("Beginning renewal loop: ttl=%s interval=%s", cfg.CertificateTTL, cfg.RenewalInterval) + if cfg.RenewalInterval > cfg.CertificateTTL { log.Errorf( "Certificate TTL (%s) is shorter than the renewal interval (%s). The next renewal is likely to fail.", cfg.CertificateTTL, - cfg.RenewInterval, + cfg.RenewalInterval, ) } @@ -674,7 +709,7 @@ func renewLoop(ctx context.Context, cfg *config.BotConfig, client auth.ClientI, return trace.Wrap(err) } - ticker := time.NewTicker(cfg.RenewInterval) + ticker := time.NewTicker(cfg.RenewalInterval) defer ticker.Stop() for { newClient, newIdentity, err := renew(ctx, cfg, client, ident, botDestination) @@ -682,6 +717,11 @@ func renewLoop(ctx context.Context, cfg *config.BotConfig, client auth.ClientI, return trace.Wrap(err) } + if cfg.Oneshot { + log.Info("Oneshot mode enabled, exiting successfully.") + break + } + client = newClient ident = newIdentity @@ -690,9 +730,12 @@ func renewLoop(ctx context.Context, cfg *config.BotConfig, client auth.ClientI, return nil case <-ticker.C: continue + case <-reloadChan: + continue } - } + + return nil } // authenticatedUserClientFromIdentity creates a new auth client from the given diff --git a/tool/tctl/common/bots_command.go b/tool/tctl/common/bots_command.go index 59848f3bb8412..882727f428054 100644 --- a/tool/tctl/common/bots_command.go +++ b/tool/tctl/common/bots_command.go @@ -67,6 +67,7 @@ func (c *BotsCommand) Initialize(app *kingpin.Application, config *service.Confi c.botsAdd.Flag("roles", "Roles the bot is able to assume.").Required().StringVar(&c.botRoles) c.botsAdd.Flag("ttl", "TTL for the bot join token.").DurationVar(&c.tokenTTL) c.botsAdd.Flag("token", "Name of an existing token to use.").StringVar(&c.tokenID) + c.botsAdd.Flag("format", "Output format, 'text' or 'json'").Hidden().Default(teleport.Text).EnumVar(&c.format, teleport.Text, teleport.JSON) // TODO: --ttl for setting a ttl on the join token c.botsRemove = bots.Command("rm", "Permanently remove a certificate renewal bot from the cluster.") @@ -150,7 +151,6 @@ Optionally, if running the bot under an isolated user account, first initialize the data directory by running the following command {{ bold "as root" }}: > tbot init \ - --auth-server={{.auth_server}} \ --destination-dir=./tbot-user \ --bot-user=tbot \ --reader-user=alice @@ -189,6 +189,16 @@ func (c *BotsCommand) AddBot(client auth.ClientI) error { return trace.WrapWithMessage(err, "error while creating bot") } + if c.format == teleport.JSON { + out, err := json.MarshalIndent(response, "", " ") + if err != nil { + return trace.Wrap(err, "failed to marshal CreateBot response") + } + + fmt.Println(string(out)) + return nil + } + // Calculate the CA pins for this cluster. The CA pins are used by the // client to verify the identity of the Auth Server. localCAResponse, err := client.GetClusterCACert()