From a880a3931e95fad865017e7252544e33f8972d6d Mon Sep 17 00:00:00 2001 From: Tim Buckley <tim@goteleport.com> Date: Tue, 10 May 2022 16:39:09 -0600 Subject: [PATCH] Add new config templates to `tbot` for databases and identity files (#11596) (#12500) * Add new `identityfile` config template to `tbot` This adds a new `identityfile` config template to tbot which generates an identity file from any of the formats supported by `tctl auth sign`. It can be used by specifying one or more formats in the configuration like so: ```yaml destinations: - directory: /foo kinds: [ssh, tls] configs: - identityfile: formats: [file] ``` It requires both SSH and TLS certificates to work properly. App, Kubernetes, and Database certs are unlikely to work as they have additional cert requirements that will be added in separate PRs. Multiple formats can be specified, and each will be written to its own subdirectory within the destination using the name of the format. The particular files written inside this directory depend on the particular format selected, but n the above example, this means a file named `/foo/file/identity` is written. The files all have an `identity` prefix at the moment. This could be made configurable if desired. The `file` format can be used in conjunction with `tsh -i` and `tctl -i` to use those tools with a tbot-generated identity. Fixes #10812 * Make identityfile formats first-class config templates This promotes most of the important identityfile formats to proper config templates. User-facing `kinds` are removed to reduce confusion and several config templates are now required. * The `ssh_client` template is now required and will be added automatically in all cases if not specified. * A new required `tls_cas` template is added to always export the current Teleport server CAs in a usable format. * A new required `identity` template is added to always export an identity file usable with tsh/tctl. * New optional `cockroach`, `mongo`, and `tls` templates can export specifically-formatted TLS certs for various databases and apps. Additionally some other changes were caught during testing: * `botfs` now allows users to specify if files should be opened for reading or for writing; previously, written files were never truncated when opened for writing leading to garbage at the end of files if the length changed. Truncation isn't sane for reading so the two use-cases are now split. * Update lib/client/identityfile/identity.go Co-authored-by: Jakub Nyckowski <jakub.nyckowski@goteleport.com> * Address first batch of review comments Tweaked the `botfs.openStandard` and `botfs.openSecure` functions to accept a plain file mode, and removed a ton of boilerplate in `configtemplate.go`. * Fix problematic nil interface check in configtemplate * Clarify comment about `client.Key` DB certs * Address review feedback - Use `DatabaseCA` for database specific templates; make the `tls` template's CA configurable; write the database CA alongside the others. - Simplify nil interface check * Fix outdated var names Co-authored-by: Jakub Nyckowski <jakub.nyckowski@goteleport.com> Co-authored-by: Jakub Nyckowski <jakub.nyckowski@goteleport.com> --- api/identityfile/identityfile.go | 10 ++ lib/client/identityfile/identity.go | 81 +++++++-- tool/tbot/botfs/botfs.go | 17 +- tool/tbot/botfs/fs_linux.go | 22 +-- tool/tbot/botfs/fs_other.go | 4 +- tool/tbot/config/config_destination.go | 85 ++++++++-- tool/tbot/config/config_test.go | 8 +- tool/tbot/config/configtemplate.go | 154 +++++++++++++++++- tool/tbot/config/configtemplate_cockroach.go | 91 +++++++++++ tool/tbot/config/configtemplate_identity.go | 88 ++++++++++ tool/tbot/config/configtemplate_mongo.go | 92 +++++++++++ ...te_ssh.go => configtemplate_ssh_client.go} | 32 ++-- tool/tbot/config/configtemplate_tls.go | 142 ++++++++++++++++ tool/tbot/config/configtemplate_tls_cas.go | 133 +++++++++++++++ tool/tbot/config/destination_directory.go | 29 +++- tool/tbot/config/destination_memory.go | 2 +- tool/tbot/configtemplate_test.go | 116 +++++++++++++ tool/tbot/destination/destination.go | 2 +- tool/tbot/identity/artifact.go | 24 ++- tool/tbot/identity/identity.go | 41 ++--- tool/tbot/identity/kinds.go | 46 +----- tool/tbot/init.go | 9 +- tool/tbot/init_test.go | 4 +- tool/tbot/main.go | 10 +- tool/tbot/renew.go | 68 +------- tool/tbot/renew_test.go | 2 - tool/tbot/testhelpers/srv.go | 8 +- 27 files changed, 1101 insertions(+), 219 deletions(-) create mode 100644 tool/tbot/config/configtemplate_cockroach.go create mode 100644 tool/tbot/config/configtemplate_identity.go create mode 100644 tool/tbot/config/configtemplate_mongo.go rename tool/tbot/config/{configtemplate_ssh.go => configtemplate_ssh_client.go} (89%) create mode 100644 tool/tbot/config/configtemplate_tls.go create mode 100644 tool/tbot/config/configtemplate_tls_cas.go create mode 100644 tool/tbot/configtemplate_test.go diff --git a/api/identityfile/identityfile.go b/api/identityfile/identityfile.go index 2f45f8cc6a749..a94824a2cd8fa 100644 --- a/api/identityfile/identityfile.go +++ b/api/identityfile/identityfile.go @@ -108,6 +108,16 @@ func Write(idFile *IdentityFile, path string) error { return nil } +// Encode encodes the given identityFile to bytes. +func Encode(idFile *IdentityFile) ([]byte, error) { + buf := new(bytes.Buffer) + if err := encodeIdentityFile(buf, idFile); err != nil { + return nil, trace.Wrap(err) + } + + return buf.Bytes(), nil +} + // Read reads an identity file from generic io.Reader interface. func Read(r io.Reader) (*IdentityFile, error) { ident, err := decodeIdentityFile(r) diff --git a/lib/client/identityfile/identity.go b/lib/client/identityfile/identity.go index 854546bb4f0a6..651e02256a97a 100644 --- a/lib/client/identityfile/identity.go +++ b/lib/client/identityfile/identity.go @@ -20,7 +20,7 @@ package identityfile import ( "context" "fmt" - "io/ioutil" + "io/fs" "os" "path/filepath" "strings" @@ -91,6 +91,40 @@ func (f FormatList) String() string { return strings.Join(elems, ", ") } +// ConfigWriter is a simple filesystem abstraction to allow alternative simple +// read/write for this package. +type ConfigWriter interface { + // WriteFile writes the given data to path `name`, using the specified + // permissions if the file is new. + WriteFile(name string, data []byte, perm os.FileMode) error + + // Remove removes a file. + Remove(name string) error + + // Stat fetches information about a file. + Stat(name string) (fs.FileInfo, error) +} + +// StandardConfigWriter is a trivial ConfigWriter that wraps the relevant `os` functions. +type StandardConfigWriter struct{} + +// WriteFile writes data to the named file, creating it if necessary. +func (s *StandardConfigWriter) WriteFile(name string, data []byte, perm os.FileMode) error { + return os.WriteFile(name, data, perm) +} + +// Remove removes the named file or (empty) directory. +// If there is an error, it will be of type *PathError. +func (s *StandardConfigWriter) Remove(name string) error { + return os.Remove(name) +} + +// Stat returns a FileInfo describing the named file. +// If there is an error, it will be of type *PathError. +func (s *StandardConfigWriter) Stat(name string) (fs.FileInfo, error) { + return os.Stat(name) +} + // WriteConfig holds the necessary information to write an identity file. type WriteConfig struct { // OutputPath is the output path for the identity file. Note that some @@ -108,11 +142,19 @@ type WriteConfig struct { // overwritten. When false, user will be prompted for confirmation of // overwrite first. OverwriteDestination bool + // Writer is the filesystem implementation. + Writer ConfigWriter } // Write writes user credentials to disk in a specified format. // It returns the names of the files successfully written. func Write(cfg WriteConfig) (filesWritten []string, err error) { + // If no writer was set, use the standard implementation. + writer := cfg.Writer + if writer == nil { + writer = &StandardConfigWriter{} + } + if cfg.OutputPath == "" { return nil, trace.BadParameter("identity output path is not specified") } @@ -121,7 +163,7 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { // dump user identity into a single file: case FormatFile: filesWritten = append(filesWritten, cfg.OutputPath) - if err := checkOverwrite(cfg.OverwriteDestination, filesWritten...); err != nil { + if err := checkOverwrite(writer, cfg.OverwriteDestination, filesWritten...); err != nil { return nil, trace.Wrap(err) } @@ -146,7 +188,12 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { idFile.CACerts.TLS = append(idFile.CACerts.TLS, ca.TLSCertificates...) } - if err := identityfile.Write(idFile, cfg.OutputPath); err != nil { + idBytes, err := identityfile.Encode(idFile) + if err != nil { + return nil, trace.Wrap(err) + } + + if err := writer.WriteFile(cfg.OutputPath, idBytes, identityfile.FilePermissions); err != nil { return nil, trace.Wrap(err) } @@ -155,16 +202,16 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { keyPath := cfg.OutputPath certPath := keypaths.IdentitySSHCertPath(keyPath) filesWritten = append(filesWritten, keyPath, certPath) - if err := checkOverwrite(cfg.OverwriteDestination, filesWritten...); err != nil { + if err := checkOverwrite(writer, cfg.OverwriteDestination, filesWritten...); err != nil { return nil, trace.Wrap(err) } - err = ioutil.WriteFile(certPath, cfg.Key.Cert, identityfile.FilePermissions) + err = writer.WriteFile(certPath, cfg.Key.Cert, identityfile.FilePermissions) if err != nil { return nil, trace.Wrap(err) } - err = ioutil.WriteFile(keyPath, cfg.Key.Priv, identityfile.FilePermissions) + err = writer.WriteFile(keyPath, cfg.Key.Priv, identityfile.FilePermissions) if err != nil { return nil, trace.Wrap(err) } @@ -182,16 +229,16 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { } filesWritten = append(filesWritten, keyPath, certPath, casPath) - if err := checkOverwrite(cfg.OverwriteDestination, filesWritten...); err != nil { + if err := checkOverwrite(writer, cfg.OverwriteDestination, filesWritten...); err != nil { return nil, trace.Wrap(err) } - err = ioutil.WriteFile(certPath, cfg.Key.TLSCert, identityfile.FilePermissions) + err = writer.WriteFile(certPath, cfg.Key.TLSCert, identityfile.FilePermissions) if err != nil { return nil, trace.Wrap(err) } - err = ioutil.WriteFile(keyPath, cfg.Key.Priv, identityfile.FilePermissions) + err = writer.WriteFile(keyPath, cfg.Key.Priv, identityfile.FilePermissions) if err != nil { return nil, trace.Wrap(err) } @@ -201,7 +248,7 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { caCerts = append(caCerts, cert...) } } - err = ioutil.WriteFile(casPath, caCerts, identityfile.FilePermissions) + err = writer.WriteFile(casPath, caCerts, identityfile.FilePermissions) if err != nil { return nil, trace.Wrap(err) } @@ -212,10 +259,10 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { certPath := cfg.OutputPath + ".crt" casPath := cfg.OutputPath + ".cas" filesWritten = append(filesWritten, certPath, casPath) - if err := checkOverwrite(cfg.OverwriteDestination, filesWritten...); err != nil { + if err := checkOverwrite(writer, cfg.OverwriteDestination, filesWritten...); err != nil { return nil, trace.Wrap(err) } - err = ioutil.WriteFile(certPath, append(cfg.Key.TLSCert, cfg.Key.Priv...), identityfile.FilePermissions) + err = writer.WriteFile(certPath, append(cfg.Key.TLSCert, cfg.Key.Priv...), identityfile.FilePermissions) if err != nil { return nil, trace.Wrap(err) } @@ -225,21 +272,21 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { caCerts = append(caCerts, cert...) } } - err = ioutil.WriteFile(casPath, caCerts, identityfile.FilePermissions) + err = writer.WriteFile(casPath, caCerts, identityfile.FilePermissions) if err != nil { return nil, trace.Wrap(err) } case FormatKubernetes: filesWritten = append(filesWritten, cfg.OutputPath) - if err := checkOverwrite(cfg.OverwriteDestination, filesWritten...); err != nil { + if err := checkOverwrite(writer, cfg.OverwriteDestination, filesWritten...); err != nil { return nil, trace.Wrap(err) } // Clean up the existing file, if it exists. // // kubeconfig.Update would try to parse it and merge in new // credentials, which is not what we want. - if err := os.Remove(cfg.OutputPath); err != nil && !os.IsNotExist(err) { + if err := writer.Remove(cfg.OutputPath); err != nil && !os.IsNotExist(err) { return nil, trace.Wrap(err) } @@ -257,11 +304,11 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { return filesWritten, nil } -func checkOverwrite(force bool, paths ...string) error { +func checkOverwrite(writer ConfigWriter, force bool, paths ...string) error { var existingFiles []string // Check if the destination file exists. for _, path := range paths { - _, err := os.Stat(path) + _, err := writer.Stat(path) if os.IsNotExist(err) { // File doesn't exist, proceed. continue diff --git a/tool/tbot/botfs/botfs.go b/tool/tbot/botfs/botfs.go index 34e3f4b279c39..33b12c3982183 100644 --- a/tool/tbot/botfs/botfs.go +++ b/tool/tbot/botfs/botfs.go @@ -68,6 +68,9 @@ const ( ACLRequired ACLMode = "required" ) +// OpenMode is a mode for opening files. +type OpenMode int + const ( // DefaultMode is the preferred permissions mode for bot files. DefaultMode fs.FileMode = 0600 @@ -77,9 +80,13 @@ const ( // contents to succeed. DefaultDirMode fs.FileMode = 0700 - // OpenMode is the mode with which files should be opened for reading and + // ReadMode is the mode with which files should be opened for reading and // writing. - OpenMode int = os.O_CREATE | os.O_RDWR + ReadMode OpenMode = OpenMode(os.O_CREATE | os.O_RDONLY) + + // WriteMode is the mode with which files should be opened specifically + // for writing. + WriteMode OpenMode = OpenMode(os.O_CREATE | os.O_WRONLY | os.O_TRUNC) ) // ACLOptions contains parameters needed to configure ACLs @@ -94,8 +101,8 @@ type ACLOptions struct { // openStandard attempts to open the given path for reading and writing with // O_CREATE set. -func openStandard(path string) (*os.File, error) { - file, err := os.OpenFile(path, OpenMode, DefaultMode) +func openStandard(path string, mode OpenMode) (*os.File, error) { + file, err := os.OpenFile(path, int(mode), DefaultMode) if err != nil { return nil, trace.ConvertSystemError(err) } @@ -114,7 +121,7 @@ func createStandard(path string, isDir bool) error { return nil } - f, err := openStandard(path) + f, err := openStandard(path, WriteMode) if err != nil { return trace.Wrap(err) } diff --git a/tool/tbot/botfs/fs_linux.go b/tool/tbot/botfs/fs_linux.go index 592c437586f1c..7541a28270369 100644 --- a/tool/tbot/botfs/fs_linux.go +++ b/tool/tbot/botfs/fs_linux.go @@ -58,12 +58,12 @@ var missingSyscallWarning sync.Once // openSecure opens the given path for writing (with O_CREAT, mode 0600) // with the RESOLVE_NO_SYMLINKS flag set. -func openSecure(path string) (*os.File, error) { +func openSecure(path string, mode OpenMode) (*os.File, error) { how := unix.OpenHow{ // Equivalent to 0600. Unfortunately it's not worth reusing our // default file mode constant here. Mode: unix.O_RDONLY | unix.S_IRUSR | unix.S_IWUSR, - Flags: uint64(OpenMode), + Flags: uint64(mode), Resolve: unix.RESOLVE_NO_SYMLINKS, } @@ -78,16 +78,16 @@ func openSecure(path string) (*os.File, error) { return os.NewFile(uintptr(fd), filepath.Base(path)), nil } -// openSymlinks mode opens the file for read/write using the given symlink +// openSymlinks mode opens the file for read or write using the given symlink // mode, potentially failing or logging a warning if symlinks can't be // secured. -func openSymlinksMode(path string, symlinksMode SymlinksMode) (*os.File, error) { +func openSymlinksMode(path string, mode OpenMode, symlinksMode SymlinksMode) (*os.File, error) { var file *os.File var err error switch symlinksMode { case SymlinksSecure: - file, err = openSecure(path) + file, err = openSecure(path, mode) if err == unix.ENOSYS { return nil, trace.Errorf("openSecure(%q) failed due to missing "+ "syscall; `symlinks: insecure` may be required for this "+ @@ -96,7 +96,7 @@ func openSymlinksMode(path string, symlinksMode SymlinksMode) (*os.File, error) return nil, trace.Wrap(err) } case SymlinksTrySecure: - file, err = openSecure(path) + file, err = openSecure(path, mode) if err == unix.ENOSYS { missingSyscallWarning.Do(func() { log.Warnf("Failed to write to %q securely due to missing "+ @@ -105,7 +105,7 @@ func openSymlinksMode(path string, symlinksMode SymlinksMode) (*os.File, error) "warning.", path) }) - file, err = openStandard(path) + file, err = openStandard(path, mode) if err != nil { return nil, trace.Wrap(err) } @@ -113,7 +113,7 @@ func openSymlinksMode(path string, symlinksMode SymlinksMode) (*os.File, error) return nil, trace.Wrap(err) } case SymlinksInsecure: - file, err = openStandard(path) + file, err = openStandard(path, mode) if err != nil { return nil, trace.Wrap(err) } @@ -139,7 +139,7 @@ func createSecure(path string, isDir bool) error { return nil } - f, err := openSecure(path) + f, err := openSecure(path, WriteMode) if err == unix.ENOSYS { // bubble up the original error for comparison return err @@ -207,7 +207,7 @@ func Create(path string, isDir bool, symlinksMode SymlinksMode) error { // Read reads the contents of the given file into memory. func Read(path string, symlinksMode SymlinksMode) ([]byte, error) { - file, err := openSymlinksMode(path, symlinksMode) + file, err := openSymlinksMode(path, ReadMode, symlinksMode) if err != nil { return nil, trace.Wrap(err) } @@ -224,7 +224,7 @@ func Read(path string, symlinksMode SymlinksMode) ([]byte, error) { // Write stores the given data to the file at the given path. func Write(path string, data []byte, symlinksMode SymlinksMode) error { - file, err := openSymlinksMode(path, symlinksMode) + file, err := openSymlinksMode(path, WriteMode, symlinksMode) if err != nil { return trace.Wrap(err) } diff --git a/tool/tbot/botfs/fs_other.go b/tool/tbot/botfs/fs_other.go index 87b2b90e3721a..3260ef4dab777 100644 --- a/tool/tbot/botfs/fs_other.go +++ b/tool/tbot/botfs/fs_other.go @@ -47,7 +47,7 @@ func Read(path string, symlinksMode SymlinksMode) ([]byte, error) { log.Warn("Secure symlinks not supported on this platform, set `symlinks: insecure` to disable this message", path) } - file, err := openStandard(path) + file, err := openStandard(path, ReadMode) if err != nil { return nil, trace.Wrap(err) } @@ -71,7 +71,7 @@ func Write(path string, data []byte, symlinksMode SymlinksMode) error { log.Warn("Secure symlinks not supported on this platform, set `symlinks: insecure` to disable this message", path) } - file, err := openStandard(path) + file, err := openStandard(path, WriteMode) if err != nil { return trace.Wrap(err) } diff --git a/tool/tbot/config/config_destination.go b/tool/tbot/config/config_destination.go index 30106522289ef..515d28dd95c7e 100644 --- a/tool/tbot/config/config_destination.go +++ b/tool/tbot/config/config_destination.go @@ -17,7 +17,6 @@ limitations under the License. package config import ( - "github.com/gravitational/teleport/tool/tbot/identity" "github.com/gravitational/trace" ) @@ -49,9 +48,8 @@ func (dc *DatabaseConfig) CheckAndSetDefaults() error { type DestinationConfig struct { DestinationMixin `yaml:",inline"` - Roles []string `yaml:"roles,omitempty"` - Kinds []identity.ArtifactKind `yaml:"kinds,omitempty"` - Configs []TemplateConfig `yaml:"configs,omitempty"` + Roles []string `yaml:"roles,omitempty"` + Configs []TemplateConfig `yaml:"configs,omitempty"` Database *DatabaseConfig `yaml:"database,omitempty"` } @@ -63,6 +61,30 @@ func destinationDefaults(dm *DestinationMixin) error { return trace.BadParameter("destinations require some valid output sink") } +// addRequiredConfigs adds all configs with default parameters that were not +// explicitly requested by users. Several configs, including `identity`, `tls`, +// and `ssh_client`, are always generated (with defaults set, if any) but will +// not be overridden if already included by the user. +func (dc *DestinationConfig) addRequiredConfigs() { + if dc.GetConfigByName(TemplateSSHClientName) == nil { + dc.Configs = append(dc.Configs, TemplateConfig{ + SSHClient: &TemplateSSHClient{}, + }) + } + + if dc.GetConfigByName(TemplateIdentityName) == nil { + dc.Configs = append(dc.Configs, TemplateConfig{ + Identity: &TemplateIdentity{}, + }) + } + + if dc.GetConfigByName(TemplateTLSCAsName) == nil { + dc.Configs = append(dc.Configs, TemplateConfig{ + TLSCAs: &TemplateTLSCAs{}, + }) + } +} + func (dc *DestinationConfig) CheckAndSetDefaults() error { if err := dc.DestinationMixin.CheckAndSetDefaults(destinationDefaults); err != nil { return trace.Wrap(err) @@ -77,12 +99,7 @@ func (dc *DestinationConfig) CheckAndSetDefaults() error { // Note: empty roles is allowed; interpreted to mean "all" at generation // time - if len(dc.Kinds) == 0 && len(dc.Configs) == 0 { - dc.Kinds = []identity.ArtifactKind{identity.KindSSH} - dc.Configs = []TemplateConfig{{ - SSHClient: &TemplateSSHClient{}, - }} - } + dc.addRequiredConfigs() for _, cfg := range dc.Configs { if err := cfg.CheckAndSetDefaults(); err != nil { @@ -93,13 +110,49 @@ func (dc *DestinationConfig) CheckAndSetDefaults() error { return nil } -// ContainsKind determines if this destination contains the given ConfigKind. -func (dc *DestinationConfig) ContainsKind(kind identity.ArtifactKind) bool { - for _, k := range dc.Kinds { - if k == kind { - return true +// ListSubdirectories lists all subdirectories that should be contained within +// this destination. Primarily used for on-the-fly directory creation. +func (dc *DestinationConfig) ListSubdirectories() ([]string, error) { + // Note: currently no standard identity.Artifacts create subdirs. If that + // ever changes, we'll need to adapt this to ensure we initialize them + // properly on the fly. + var subdirs []string + + for _, config := range dc.Configs { + template, err := config.GetConfigTemplate() + if err != nil { + return nil, trace.Wrap(err) + } + + for _, file := range template.Describe() { + if file.IsDir { + subdirs = append(subdirs, file.Name) + } } } - return false + return subdirs, nil +} + +// GetConfigByName returns the first valid template with the given name +// contained within this destination. +func (dc *DestinationConfig) GetConfigByName(name string) Template { + for _, cfg := range dc.Configs { + tpl, err := cfg.GetConfigTemplate() + if err != nil { + continue + } + + if tpl.Name() == name { + return tpl + } + } + + return nil +} + +// GetRequiredConfig returns the static list of all default / required config +// templates. +func GetRequiredConfigs() []string { + return []string{TemplateTLSCAsName, TemplateSSHClientName, TemplateIdentityName} } diff --git a/tool/tbot/config/config_test.go b/tool/tbot/config/config_test.go index 9d583a480ed1f..ef3fb92c5ac37 100644 --- a/tool/tbot/config/config_test.go +++ b/tool/tbot/config/config_test.go @@ -22,7 +22,6 @@ import ( "time" "github.com/coreos/go-semver/semver" - "github.com/gravitational/teleport/tool/tbot/identity" "github.com/stretchr/testify/require" ) @@ -75,9 +74,9 @@ func TestConfigCLIOnlySample(t *testing.T) { // A single default destination should exist require.Len(t, cfg.Destinations, 1) dest := cfg.Destinations[0] - require.ElementsMatch(t, []identity.ArtifactKind{identity.KindSSH}, dest.Kinds) - require.Len(t, dest.Configs, 1) + // We have 3 required/default templates. + require.Len(t, dest.Configs, 3) template := dest.Configs[0] require.NotNil(t, template.SSHClient) @@ -109,8 +108,6 @@ func TestConfigFile(t *testing.T) { require.Len(t, cfg.Destinations, 1) destination := cfg.Destinations[0] - require.ElementsMatch(t, []identity.ArtifactKind{identity.KindSSH, identity.KindTLS}, destination.Kinds) - require.Len(t, destination.Configs, 1) template := destination.Configs[0] templateImpl, err := template.GetConfigTemplate() @@ -182,7 +179,6 @@ storage: destinations: - directory: path: /tmp/foo - kinds: [ssh, tls] configs: - ssh_client: proxy_port: 1234 diff --git a/tool/tbot/config/configtemplate.go b/tool/tbot/config/configtemplate.go index f54e86a6ee165..2f4d3ea4b2380 100644 --- a/tool/tbot/config/configtemplate.go +++ b/tool/tbot/config/configtemplate.go @@ -18,19 +18,53 @@ package config import ( "context" + "io/fs" + "os" + "path" + "reflect" "strings" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/tool/tbot/destination" "github.com/gravitational/teleport/tool/tbot/identity" "github.com/gravitational/trace" "gopkg.in/yaml.v3" ) -const TemplateSSHClientName = "ssh_client" +const ( + // TemplateSSHClientName is the config name for generating ssh client + // config files. + TemplateSSHClientName = "ssh_client" + + // TemplateIdentityName is the config name for Teleport identity files. + TemplateIdentityName = "identity" + + // TemplateTLSName is the config name for TLS client certificates. + TemplateTLSName = "tls" + + // TemplateTLSCAsName is the config name for TLS CA certificates. + TemplateTLSCAsName = "tls_cas" + + // TemplateMongoName is the config name for MongoDB-formatted certificates. + TemplateMongoName = "mongo" + + // TemplateCockroachName is the config name for CockroachDB-formatted + // certificates. + TemplateCockroachName = "cockroach" +) // AllConfigTemplates lists all valid config templates, intended for help // messages -var AllConfigTemplates = [...]string{TemplateSSHClientName} +var AllConfigTemplates = [...]string{ + TemplateSSHClientName, + TemplateIdentityName, + TemplateTLSName, + TemplateTLSCAsName, + TemplateMongoName, + TemplateCockroachName, +} // FileDescription is a minimal spec needed to create an empty end-user-owned // file with bot-writable ACLs during `tbot init`. @@ -46,6 +80,9 @@ type FileDescription struct { // Template defines functions for dynamically writing additional files to // a Destination. type Template interface { + // Name returns the name of this config template. + Name() string + // Describe generates a list of all files this ConfigTemplate will generate // at runtime. Currently ConfigTemplates are required to know this // statically as this must be callable without any auth clients (or any @@ -61,6 +98,11 @@ type Template interface { // variant must be set to be considered valid. type TemplateConfig struct { SSHClient *TemplateSSHClient `yaml:"ssh_client,omitempty"` + Identity *TemplateIdentity `yaml:"identity,omitempty"` + TLS *TemplateTLS `yaml:"tls,omitempty"` + TLSCAs *TemplateTLSCAs `yaml:"tls_cas,omitempty"` + Mongo *TemplateMongo `yaml:"mongo,omitempty"` + Cockroach *TemplateCockroach `yaml:"cockroach,omitempty"` } func (c *TemplateConfig) UnmarshalYAML(node *yaml.Node) error { @@ -75,6 +117,16 @@ func (c *TemplateConfig) UnmarshalYAML(node *yaml.Node) error { switch simpleTemplate { case TemplateSSHClientName: c.SSHClient = &TemplateSSHClient{} + case TemplateIdentityName: + c.Identity = &TemplateIdentity{} + case TemplateTLSName: + c.TLS = &TemplateTLS{} + case TemplateTLSCAsName: + c.TLSCAs = &TemplateTLSCAs{} + case TemplateMongoName: + c.Mongo = &TemplateMongo{} + case TemplateCockroachName: + c.Cockroach = &TemplateCockroach{} default: return trace.BadParameter( "invalid config template '%s' on line %d, expected one of: %s", @@ -91,11 +143,31 @@ func (c *TemplateConfig) UnmarshalYAML(node *yaml.Node) error { } func (c *TemplateConfig) CheckAndSetDefaults() error { + templates := []interface{ CheckAndSetDefaults() error }{ + c.SSHClient, + c.Identity, + c.TLS, + c.TLSCAs, + c.Mongo, + c.Cockroach, + } + notNilCount := 0 + for _, template := range templates { + // Note: this check is fragile and will fail if the templates aren't + // all simple pointer types. They are, though, and the "correct" + // solution is insane, so we'll stick with this. + if reflect.ValueOf(template).IsNil() { + continue + } + + if template != nil { + if err := template.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err) + } - if c.SSHClient != nil { - c.SSHClient.CheckAndSetDefaults() - notNilCount++ + notNilCount++ + } } if notNilCount == 0 { @@ -107,10 +179,78 @@ func (c *TemplateConfig) CheckAndSetDefaults() error { return nil } +// GetConfigTemplate returns the first not-nil config template implementation +// in the struct. func (c *TemplateConfig) GetConfigTemplate() (Template, error) { - if c.SSHClient != nil { - return c.SSHClient, nil + templates := []Template{ + c.SSHClient, + c.Identity, + c.TLS, + c.TLSCAs, + c.Mongo, + c.Cockroach, + } + + for _, template := range templates { + // Note: same caveats as above. + if reflect.ValueOf(template).IsNil() { + continue + } + + return template, nil } return nil, trace.BadParameter("no valid config template") } + +// BotConfigWriter is a trivial adapter to use the identityfile package with +// bot destinations. +type BotConfigWriter struct { + // dest is the destination that will handle writing of files. + dest destination.Destination + + // subpath is the subdirectory within the destination to which the files + // should be written. + subpath string +} + +// WriteFile writes the file to the destination. Only the basename of the path +// is used. Specified permissions are ignored. +func (b *BotConfigWriter) WriteFile(name string, data []byte, _ os.FileMode) error { + p := path.Base(name) + if b.subpath != "" { + p = path.Join(b.subpath, p) + } + + return trace.Wrap(b.dest.Write(p, data)) +} + +// Remove removes files. This is a dummy implementation that always returns not found. +func (b *BotConfigWriter) Remove(name string) error { + return &os.PathError{Op: "stat", Path: name, Err: os.ErrNotExist} +} + +// Stat checks file status. This implementation always returns not found. +func (b *BotConfigWriter) Stat(name string) (fs.FileInfo, error) { + return nil, &os.PathError{Op: "stat", Path: name, Err: os.ErrNotExist} +} + +// newClientKey returns a sane client.Key for the given bot identity. +func newClientKey(ident *identity.Identity, hostCAs []types.CertAuthority) *client.Key { + return &client.Key{ + KeyIndex: client.KeyIndex{ + ClusterName: ident.ClusterName, + }, + Priv: ident.PrivateKeyBytes, + Pub: ident.PublicKeyBytes, + Cert: ident.CertBytes, + TLSCert: ident.TLSCertBytes, + TrustedCA: auth.AuthoritiesToTrustedCerts(hostCAs), + + // Note: these fields are never used or persisted with identity files, + // so we won't bother to set them. (They may need to be reconstituted + // on tsh's end based on cert fields, though.) + KubeTLSCerts: make(map[string][]byte), + DBTLSCerts: make(map[string][]byte), + } +} diff --git a/tool/tbot/config/configtemplate_cockroach.go b/tool/tbot/config/configtemplate_cockroach.go new file mode 100644 index 0000000000000..5c43bb0a467e9 --- /dev/null +++ b/tool/tbot/config/configtemplate_cockroach.go @@ -0,0 +1,91 @@ +/* +Copyright 2022 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "context" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/client/identityfile" + "github.com/gravitational/teleport/tool/tbot/identity" + "github.com/gravitational/trace" +) + +const defaultCockroachDirName = "cockroach" + +// TemplateCockroach generates certificates for CockroachDB. These are standard +// TLS certs but have specific naming requirements. We write them to a +// subdirectory to ensure naming is clear. +type TemplateCockroach struct { + DirName string `yaml:"dir_name,omitempty"` +} + +func (t *TemplateCockroach) CheckAndSetDefaults() error { + if t.DirName == "" { + t.DirName = defaultCockroachDirName + } + + return nil +} + +func (t *TemplateCockroach) Name() string { + return TemplateCockroachName +} + +func (t *TemplateCockroach) Describe() []FileDescription { + return []FileDescription{ + { + Name: t.DirName, + IsDir: true, + }, + } +} + +func (t *TemplateCockroach) Render(ctx context.Context, authClient auth.ClientI, currentIdentity *identity.Identity, destination *DestinationConfig) error { + dest, err := destination.GetDestination() + if err != nil { + return trace.Wrap(err) + } + + dbCAs, err := authClient.GetCertAuthorities(ctx, types.HostCA, false) + if err != nil { + return trace.Wrap(err) + } + + cfg := identityfile.WriteConfig{ + OutputPath: t.DirName, + Writer: &BotConfigWriter{ + dest: dest, + subpath: t.DirName, + }, + Key: newClientKey(currentIdentity, dbCAs), + Format: identityfile.FormatCockroach, + + // Always overwrite to avoid hitting our no-op Stat() and Remove() functions. + OverwriteDestination: true, + } + + files, err := identityfile.Write(cfg) + if err != nil { + return trace.Wrap(err) + } + + log.Debugf("Wrote CockroachDB files: %+v", files) + + return nil +} diff --git a/tool/tbot/config/configtemplate_identity.go b/tool/tbot/config/configtemplate_identity.go new file mode 100644 index 0000000000000..f76ff8d39c40e --- /dev/null +++ b/tool/tbot/config/configtemplate_identity.go @@ -0,0 +1,88 @@ +/* +Copyright 2022 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "context" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/client/identityfile" + "github.com/gravitational/teleport/tool/tbot/identity" + "github.com/gravitational/trace" +) + +const defaultIdentityFileName = "identity" + +// TemplateIdentity is a config template that generates a Teleport identity +// file that can be used by tsh and tctl. +type TemplateIdentity struct { + FileName string `yaml:"file_name,omitempty"` +} + +func (t *TemplateIdentity) CheckAndSetDefaults() error { + if t.FileName == "" { + t.FileName = defaultIdentityFileName + } + + return nil +} + +func (t *TemplateIdentity) Name() string { + return TemplateIdentityName +} + +func (t *TemplateIdentity) Describe() []FileDescription { + return []FileDescription{ + { + Name: t.FileName, + }, + } +} + +func (t *TemplateIdentity) Render(ctx context.Context, authClient auth.ClientI, currentIdentity *identity.Identity, destination *DestinationConfig) error { + dest, err := destination.GetDestination() + if err != nil { + return trace.Wrap(err) + } + + hostCAs, err := authClient.GetCertAuthorities(ctx, types.HostCA, false) + if err != nil { + return trace.Wrap(err) + } + + cfg := identityfile.WriteConfig{ + OutputPath: t.FileName, + Writer: &BotConfigWriter{ + dest: dest, + }, + Key: newClientKey(currentIdentity, hostCAs), + Format: identityfile.FormatFile, + + // Always overwrite to avoid hitting our no-op Stat() and Remove() functions. + OverwriteDestination: true, + } + + files, err := identityfile.Write(cfg) + if err != nil { + return trace.Wrap(err) + } + + log.Debugf("Wrote identity file: %+v", files) + + return nil +} diff --git a/tool/tbot/config/configtemplate_mongo.go b/tool/tbot/config/configtemplate_mongo.go new file mode 100644 index 0000000000000..b659e3b5c6998 --- /dev/null +++ b/tool/tbot/config/configtemplate_mongo.go @@ -0,0 +1,92 @@ +/* +Copyright 2022 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "context" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/client/identityfile" + "github.com/gravitational/teleport/tool/tbot/identity" + "github.com/gravitational/trace" +) + +// defaultMongoPrefix is the default prefix in generated MongoDB certs. +const defaultMongoPrefix = "mongo" + +// TemplateMongo is a config template that generates TLS certs formatted for +// use with MongoDB. +type TemplateMongo struct { + Prefix string `yaml:"prefix,omitempty"` +} + +func (t *TemplateMongo) CheckAndSetDefaults() error { + if t.Prefix == "" { + t.Prefix = defaultMongoPrefix + } + + return nil +} + +func (t *TemplateMongo) Name() string { + return TemplateMongoName +} + +func (t *TemplateMongo) Describe() []FileDescription { + return []FileDescription{ + { + Name: t.Prefix + ".crt", + }, + { + Name: t.Prefix + ".cas", + }, + } +} + +func (t *TemplateMongo) Render(ctx context.Context, authClient auth.ClientI, currentIdentity *identity.Identity, destination *DestinationConfig) error { + dest, err := destination.GetDestination() + if err != nil { + return trace.Wrap(err) + } + + dbCAs, err := authClient.GetCertAuthorities(ctx, types.HostCA, false) + if err != nil { + return trace.Wrap(err) + } + + cfg := identityfile.WriteConfig{ + OutputPath: t.Prefix, + Writer: &BotConfigWriter{ + dest: dest, + }, + Key: newClientKey(currentIdentity, dbCAs), + Format: identityfile.FormatMongo, + + // Always overwrite to avoid hitting our no-op Stat() and Remove() functions. + OverwriteDestination: true, + } + + files, err := identityfile.Write(cfg) + if err != nil { + return trace.Wrap(err) + } + + log.Debugf("Wrote MongoDB identity files: %+v", files) + + return nil +} diff --git a/tool/tbot/config/configtemplate_ssh.go b/tool/tbot/config/configtemplate_ssh_client.go similarity index 89% rename from tool/tbot/config/configtemplate_ssh.go rename to tool/tbot/config/configtemplate_ssh_client.go index 2c8f7da6d9550..7ae641d7c8944 100644 --- a/tool/tbot/config/configtemplate_ssh.go +++ b/tool/tbot/config/configtemplate_ssh_client.go @@ -20,7 +20,6 @@ import ( "bytes" "context" "fmt" - "os" "os/exec" "path/filepath" "regexp" @@ -51,7 +50,16 @@ var openSSHVersionRegex = regexp.MustCompile(`^OpenSSH_(?P<major>\d+)\.(?P<minor // RSA deprecation workaround should be added to generated ssh_config. var openSSHMinVersionForRSAWorkaround = semver.New("8.5.0") -// parseSSHVersion attempts to parse +const ( + // sshConfigName is the name of the ssh_config file on disk + sshConfigName = "ssh_config" + + // knownHostsName is the name of the known_hosts file on disk + knownHostsName = "known_hosts" +) + +// parseSSHVersion attempts to parse the local SSH version, used to determine +// certain config template parameters for client version compatibility. func parseSSHVersion(versionString string) (*semver.Version, error) { versionTokens := strings.Split(versionString, " ") if len(versionTokens) == 0 { @@ -111,6 +119,10 @@ func (c *TemplateSSHClient) CheckAndSetDefaults() error { return nil } +func (c *TemplateSSHClient) Name() string { + return TemplateSSHClientName +} + func (c *TemplateSSHClient) Describe() []FileDescription { return []FileDescription{ { @@ -123,10 +135,6 @@ func (c *TemplateSSHClient) Describe() []FileDescription { } func (c *TemplateSSHClient) Render(ctx context.Context, authClient auth.ClientI, currentIdentity *identity.Identity, destination *DestinationConfig) error { - if !destination.ContainsKind(identity.KindSSH) { - return trace.BadParameter("%s config template requires kind `ssh` to be enabled", TemplateSSHClientName) - } - dest, err := destination.GetDestination() if err != nil { return trace.Wrap(err) @@ -154,7 +162,9 @@ func (c *TemplateSSHClient) Render(ctx context.Context, authClient auth.ClientI, // Backend note: Prefer to use absolute paths for filesystem backends. // If the backend is something else, use "". ssh_config will generate with - // paths relative to the destination. + // paths relative to the destination. This doesn't work with ssh in + // practice so adjusting the config for impossible-to-determine-in-advance + // destination backends is left as an exercise to the user. var dataDir string if dir, ok := dest.(*DestinationDirectory); ok { dataDir, err = filepath.Abs(dir.Path) @@ -170,8 +180,8 @@ func (c *TemplateSSHClient) Render(ctx context.Context, authClient auth.ClientI, return trace.Wrap(err) } - knownHostsPath := filepath.Join(dataDir, "known_hosts") - if err := os.WriteFile(knownHostsPath, []byte(knownHosts), 0600); err != nil { + knownHostsPath := filepath.Join(dataDir, knownHostsName) + if err := dest.Write(knownHostsName, []byte(knownHosts)); err != nil { return trace.Wrap(err) } @@ -190,7 +200,7 @@ func (c *TemplateSSHClient) Render(ctx context.Context, authClient auth.ClientI, var sshConfigBuilder strings.Builder identityFilePath := filepath.Join(dataDir, identity.PrivateKeyKey) certificateFilePath := filepath.Join(dataDir, identity.SSHCertKey) - sshConfigPath := filepath.Join(dataDir, "ssh_config") + sshConfigPath := filepath.Join(dataDir, sshConfigName) if err := sshConfigTemplate.Execute(&sshConfigBuilder, sshConfigParameters{ ClusterName: clusterName.GetClusterName(), ProxyHost: proxyHost, @@ -204,7 +214,7 @@ func (c *TemplateSSHClient) Render(ctx context.Context, authClient auth.ClientI, return trace.Wrap(err) } - if err := os.WriteFile(sshConfigPath, []byte(sshConfigBuilder.String()), 0600); err != nil { + if err := dest.Write(sshConfigName, []byte(sshConfigBuilder.String())); err != nil { return trace.Wrap(err) } diff --git a/tool/tbot/config/configtemplate_tls.go b/tool/tbot/config/configtemplate_tls.go new file mode 100644 index 0000000000000..9871d11b11ad3 --- /dev/null +++ b/tool/tbot/config/configtemplate_tls.go @@ -0,0 +1,142 @@ +/* +Copyright 2022 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "context" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/client/identityfile" + "github.com/gravitational/teleport/tool/tbot/identity" + "github.com/gravitational/trace" + "gopkg.in/yaml.v3" +) + +const defaultTLSPrefix = "tls" + +// CertAuthType is a types.CertAuthType wrapper with unmarshalling support. +type CertAuthType types.CertAuthType + +const defaultCAType = types.HostCA + +func (c *CertAuthType) UnmarshalYAML(node *yaml.Node) error { + var certType string + err := node.Decode(&certType) + if err != nil { + return trace.Wrap(err) + } + + switch certType { + case "": + *c = CertAuthType(defaultCAType) + case string(types.HostCA), string(types.UserCA): + *c = CertAuthType(certType) + default: + return trace.BadParameter("invalid CA certificate type: %q", certType) + } + + return nil +} + +func (c *CertAuthType) CheckAndSetDefaults() error { + switch types.CertAuthType(*c) { + case "": + *c = CertAuthType(defaultCAType) + case types.HostCA, types.UserCA: + // valid, nothing to do + default: + return trace.BadParameter("unsupported CA certificate type: %q", string(*c)) + } + + return nil +} + +// TemplateTLS is a config template that wraps identityfile's TLS writer. +// It's not generally needed but can be used to write out TLS certificates with +// alternative prefix and file extensions if needed for application +// compatibility reasons. +type TemplateTLS struct { + // Prefix is the filename prefix for the output files. + Prefix string `yaml:"prefix,omitempty"` + + // CACertType is the type of CA cert to be written + CACertType CertAuthType `yaml:"ca_cert_type,omitempty"` +} + +func (t *TemplateTLS) CheckAndSetDefaults() error { + if t.Prefix == "" { + t.Prefix = defaultTLSPrefix + } + + if err := t.CACertType.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err) + } + + return nil +} + +func (t *TemplateTLS) Name() string { + return TemplateTLSName +} + +func (t *TemplateTLS) Describe() []FileDescription { + return []FileDescription{ + { + Name: t.Prefix + ".key", + }, + { + Name: t.Prefix + ".crt", + }, + { + Name: t.Prefix + ".cas", + }, + } +} + +func (t *TemplateTLS) Render(ctx context.Context, authClient auth.ClientI, currentIdentity *identity.Identity, destination *DestinationConfig) error { + dest, err := destination.GetDestination() + if err != nil { + return trace.Wrap(err) + } + + cas, err := authClient.GetCertAuthorities(ctx, types.CertAuthType(t.CACertType), false) + if err != nil { + return trace.Wrap(err) + } + + cfg := identityfile.WriteConfig{ + OutputPath: t.Prefix, + Writer: &BotConfigWriter{ + dest: dest, + }, + Key: newClientKey(currentIdentity, cas), + Format: identityfile.FormatTLS, + + // Always overwrite to avoid hitting our no-op Stat() and Remove() functions. + OverwriteDestination: true, + } + + files, err := identityfile.Write(cfg) + if err != nil { + return trace.Wrap(err) + } + + log.Debugf("Wrote TLS identity files: %+v", files) + + return nil +} diff --git a/tool/tbot/config/configtemplate_tls_cas.go b/tool/tbot/config/configtemplate_tls_cas.go new file mode 100644 index 0000000000000..adda4371280bd --- /dev/null +++ b/tool/tbot/config/configtemplate_tls_cas.go @@ -0,0 +1,133 @@ +/* +Copyright 2022 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "context" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/tool/tbot/identity" + "github.com/gravitational/trace" +) + +const ( + // defaultHostCAPath is the default filename for the host CA certificate + defaultHostCAPath = "teleport-host-ca.crt" + + // defaultUserCAPath is the default filename for the user CA certificate + defaultUserCAPath = "teleport-user-ca.crt" + + // defaultDatabaseCAPath is the default filename for the database CA + // certificate + defaultDatabaseCAPath = "teleport-database-ca.crt" +) + +// TemplateTLSCAs outputs Teleport's host and user CAs for miscellaneous TLS +// client use. +type TemplateTLSCAs struct { + // HostCAPath is the path to which Teleport's host CAs will be written. + HostCAPath string `yaml:"host_ca_path,omitempty"` + + // UserCAPath is the path to which Teleport's user CAs will be written. + UserCAPath string `yaml:"user_ca_path,omitempty"` + + // DatabaseCAPath is the path to which Teleport's database CA will be + // written. + DatabaseCAPath string `yaml:"database_ca_path,omitempty"` +} + +func (t *TemplateTLSCAs) CheckAndSetDefaults() error { + // As much as it seems silly to make these configurable, some apps require + // certs to have a certain name / file extension and it's trivial to make + // that configurable. + + if t.HostCAPath == "" { + t.HostCAPath = defaultHostCAPath + } + + if t.UserCAPath == "" { + t.UserCAPath = defaultUserCAPath + } + + if t.DatabaseCAPath == "" { + t.DatabaseCAPath = defaultDatabaseCAPath + } + + return nil +} + +func (t *TemplateTLSCAs) Name() string { + return TemplateTLSCAsName +} + +func (t *TemplateTLSCAs) Describe() []FileDescription { + return []FileDescription{ + { + Name: t.UserCAPath, + }, + { + Name: t.HostCAPath, + }, + } +} + +// concatCACerts borrow's identityfile's CA cert concat method. +func concatCACerts(cas []types.CertAuthority) []byte { + trusted := auth.AuthoritiesToTrustedCerts(cas) + + var caCerts []byte + for _, ca := range trusted { + for _, cert := range ca.TLSCertificates { + caCerts = append(caCerts, cert...) + } + } + + return caCerts +} + +func (t *TemplateTLSCAs) Render(ctx context.Context, authClient auth.ClientI, currentIdentity *identity.Identity, destination *DestinationConfig) error { + hostCAs, err := authClient.GetCertAuthorities(ctx, types.HostCA, false) + if err != nil { + return trace.Wrap(err) + } + + userCAs, err := authClient.GetCertAuthorities(ctx, types.UserCA, false) + if err != nil { + return trace.Wrap(err) + } + + dest, err := destination.GetDestination() + if err != nil { + return trace.Wrap(err) + } + + // Note: This implementation mirrors tctl's current behavior. I've noticed + // that mariadb at least does not seem to like being passed more than one + // CA so there may be some compat issues to address in the future for the + // rare case where a CA rotation is in progress. + + if err := dest.Write(t.HostCAPath, concatCACerts(hostCAs)); err != nil { + return trace.Wrap(err) + } + + if err := dest.Write(t.UserCAPath, concatCACerts(userCAs)); err != nil { + return trace.Wrap(err) + } + + return nil +} diff --git a/tool/tbot/config/destination_directory.go b/tool/tbot/config/destination_directory.go index b3b39cd7da38c..21d1cccd322bc 100644 --- a/tool/tbot/config/destination_directory.go +++ b/tool/tbot/config/destination_directory.go @@ -20,6 +20,7 @@ import ( "fmt" "os" "os/user" + "path" "path/filepath" "github.com/gravitational/teleport/tool/tbot/botfs" @@ -115,18 +116,34 @@ func (dd *DestinationDirectory) CheckAndSetDefaults() error { return nil } -func (dd *DestinationDirectory) Init() error { - // Create the directory if needed. - stat, err := os.Stat(dd.Path) +// mkdir attempts to make the given directory with extra logging. +func mkdir(p string) error { + stat, err := os.Stat(p) if trace.IsNotFound(err) { - if err := os.MkdirAll(dd.Path, botfs.DefaultDirMode); err != nil { + if err := os.MkdirAll(p, botfs.DefaultDirMode); err != nil { return trace.Wrap(err) } - log.Infof("Created directory %q", dd.Path) + + log.Infof("Created directory %q", p) } else if err != nil { return trace.Wrap(err) } else if !stat.IsDir() { - return trace.BadParameter("Path %q already exists and is not a directory", dd.Path) + return trace.BadParameter("Path %q already exists and is not a directory", p) + } + + return nil +} + +func (dd *DestinationDirectory) Init(subdirs []string) error { + // Create the directory if needed. + if err := mkdir(dd.Path); err != nil { + return trace.Wrap(err) + } + + for _, dir := range subdirs { + if err := mkdir(path.Join(dd.Path, dir)); err != nil { + return trace.Wrap(err) + } } return nil diff --git a/tool/tbot/config/destination_memory.go b/tool/tbot/config/destination_memory.go index 2778bc838a52e..63b20e7146130 100644 --- a/tool/tbot/config/destination_memory.go +++ b/tool/tbot/config/destination_memory.go @@ -50,7 +50,7 @@ func (dm *DestinationMemory) CheckAndSetDefaults() error { return nil } -func (dm *DestinationMemory) Init() error { +func (dm *DestinationMemory) Init(subdirs []string) error { // Nothing to do. return nil } diff --git a/tool/tbot/configtemplate_test.go b/tool/tbot/configtemplate_test.go new file mode 100644 index 0000000000000..52706521d5754 --- /dev/null +++ b/tool/tbot/configtemplate_test.go @@ -0,0 +1,116 @@ +/* +Copyright 2022 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "bytes" + "context" + "testing" + + "github.com/gravitational/teleport/api/identityfile" + "github.com/gravitational/teleport/lib/tlsca" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/tool/tbot/config" + "github.com/gravitational/teleport/tool/tbot/destination" + "github.com/gravitational/teleport/tool/tbot/testhelpers" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +// Note: This test lives in main to avoid otherwise inevitable import cycles +// if we tried importing renewal code from the config package. + +// validateTemplate loads and validates a config template from the destination +func validateTemplate(t *testing.T, tplI config.Template, dest destination.Destination) { + t.Helper() + + // First, make sure all advertised files exist. + for _, file := range tplI.Describe() { + // Don't bother checking directories, they're meant to be black + // boxes. We could implement type-specific checks if we really + // wanted. + if file.IsDir { + continue + } + + bytes, err := dest.Read(file.Name) + require.NoError(t, err) + + // Should at least be non-empty. + t.Logf("Expected file %q for template %q has length: %d", file.Name, tplI.Name(), len(bytes)) + require.Truef(t, len(bytes) > 0, "file %q in template %q must be non-empty", file.Name, tplI.Name()) + } + + // Next, for supported template types, make sure they're valid. + // TODO: consider adding further type-specific tests. + switch tpl := tplI.(type) { + case *config.TemplateIdentity: + // Make sure the identityfile package can read this identity file. + b, err := dest.Read(tpl.FileName) + require.NoError(t, err) + + buf := bytes.NewBuffer(b) + _, err = identityfile.Read(buf) + require.NoError(t, err) + case *config.TemplateTLSCAs: + b, err := dest.Read(tpl.HostCAPath) + require.NoError(t, err) + _, err = tlsca.ParseCertificatePEM(b) + require.NoError(t, err) + + b, err = dest.Read(tpl.UserCAPath) + require.NoError(t, err) + _, err = tlsca.ParseCertificatePEM(b) + require.NoError(t, err) + } +} + +// TestTemplateRendering performs a full renewal and ensures all expected +// default config templates are present. +func TestDefaultTemplateRendering(t *testing.T) { + utils.InitLogger(utils.LoggingForDaemon, logrus.DebugLevel) + + // Make a new auth server. + fc := testhelpers.DefaultConfig(t) + _ = testhelpers.MakeAndRunTestAuthServer(t, fc) + rootClient := testhelpers.MakeDefaultAuthClient(t, fc) + + // Make and join a new bot instance. + botParams := testhelpers.MakeBot(t, rootClient, "test") + botConfig := testhelpers.MakeMemoryBotConfig(t, fc, botParams) + storage, err := botConfig.Storage.GetDestination() + require.NoError(t, err) + + ident, err := getIdentityFromToken(botConfig) + require.NoError(t, err) + + botClient := testhelpers.MakeBotAuthClient(t, fc, ident) + + _, _, err = renew(context.Background(), botConfig, botClient, ident, storage) + require.NoError(t, err) + + dest := botConfig.Destinations[0] + destImpl, err := dest.GetDestination() + require.NoError(t, err) + + for _, templateName := range config.GetRequiredConfigs() { + cfg := dest.GetConfigByName(templateName) + require.NotNilf(t, cfg, "template %q must exist", templateName) + + validateTemplate(t, cfg, destImpl) + } +} diff --git a/tool/tbot/destination/destination.go b/tool/tbot/destination/destination.go index 3ad0ca5358b3d..9c7addcfe1b11 100644 --- a/tool/tbot/destination/destination.go +++ b/tool/tbot/destination/destination.go @@ -21,7 +21,7 @@ type Destination interface { // Init attempts to initialize this destination for writing. Init should be // idempotent and may write informational log messages if resources are // created. - Init() error + Init(subdirs []string) error // Verify is run before renewals to check for any potential problems with // the destination. These errors may be informational (logged warnings) or diff --git a/tool/tbot/identity/artifact.go b/tool/tbot/identity/artifact.go index 7716a177603ad..3ace0ed0398cc 100644 --- a/tool/tbot/identity/artifact.go +++ b/tool/tbot/identity/artifact.go @@ -51,7 +51,7 @@ var artifacts = []Artifact{ // SSH artifacts { Key: SSHCertKey, - Kind: KindSSH, + Kind: KindAlways, ToBytes: func(i *Identity) []byte { return i.CertBytes }, @@ -60,8 +60,13 @@ var artifacts = []Artifact{ }, }, { - Key: SSHCACertsKey, - Kind: KindSSH, + Key: SSHCACertsKey, + + // SSH CAs in this format are only used for saving/loading of bot + // identities and are not particularly useful to end users. We encode + // the current SSH CAs inside the known_hosts file generated with the + // `ssh_config` template, which is actually readable by OpenSSH. + Kind: KindBotInternal, ToBytes: func(i *Identity) []byte { return bytes.Join(i.SSHCACertBytes, []byte("$")) }, @@ -73,7 +78,7 @@ var artifacts = []Artifact{ // TLS artifacts { Key: TLSCertKey, - Kind: KindTLS, + Kind: KindAlways, ToBytes: func(i *Identity) []byte { return i.TLSCertBytes }, @@ -82,8 +87,15 @@ var artifacts = []Artifact{ }, }, { - Key: TLSCACertsKey, - Kind: KindTLS, + Key: TLSCACertsKey, + + // TLS CA certs are useful to end users, but this artifact contains an + // arbitrary number of CAs, including both Teleport's user and host CAs + // and potentially multiple sets if they've been rotated. + // Instead of exposing this mess of CAs to end users, we'll keep these + // for internal use and just present single standard CAs in destination + // dirs. + Kind: KindBotInternal, ToBytes: func(i *Identity) []byte { return bytes.Join(i.TLSCACertsBytes, []byte("$")) }, diff --git a/tool/tbot/identity/identity.go b/tool/tbot/identity/identity.go index 5543931cf4479..06fce471db6af 100644 --- a/tool/tbot/identity/identity.go +++ b/tool/tbot/identity/identity.go @@ -248,30 +248,33 @@ func (i *Identity) SSHClientConfig() (*ssh.ClientConfig, error) { // ReadIdentityFromStore reads stored identity credentials func ReadIdentityFromStore(params *LoadIdentityParams, certs *proto.Certs, kinds ...ArtifactKind) (*Identity, error) { var identity Identity - if ContainsKind(KindSSH, kinds) { - if len(certs.SSH) == 0 { - return nil, trace.BadParameter("identity requires SSH certificates but they are unset") - } - err := ReadSSHIdentityFromKeyPair(&identity, params.PrivateKeyBytes, params.PrivateKeyBytes, certs.SSH) - if err != nil { - return nil, trace.Wrap(err) - } + // Note: in practice we should always expect certificates to have all + // fields set even though destinations do not contain sufficient data to + // load a stored identity. This works in practice because we never read + // destination identities from disk and only read them from the result of + // `generateUserCerts`, which is always fully-formed. - if len(certs.SSHCACerts) != 0 { - identity.SSHCACertBytes = certs.SSHCACerts - } + if len(certs.SSH) == 0 { + return nil, trace.BadParameter("identity requires SSH certificates but they are unset") } - if ContainsKind(KindTLS, kinds) { - if len(certs.TLSCACerts) == 0 || len(certs.TLS) == 0 { - return nil, trace.BadParameter("identity requires TLS certificates but they are empty") - } + if len(certs.TLSCACerts) == 0 || len(certs.TLS) == 0 { + return nil, trace.BadParameter("identity requires TLS certificates but they are empty") + } - // Parse the key pair to verify that identity parses properly for future use. - if err := ReadTLSIdentityFromKeyPair(&identity, params.PrivateKeyBytes, certs.TLS, certs.TLSCACerts); err != nil { - return nil, trace.Wrap(err) - } + err := ReadSSHIdentityFromKeyPair(&identity, params.PrivateKeyBytes, params.PrivateKeyBytes, certs.SSH) + if err != nil { + return nil, trace.Wrap(err) + } + + if len(certs.SSHCACerts) != 0 { + identity.SSHCACertBytes = certs.SSHCACerts + } + + // Parse the key pair to verify that identity parses properly for future use. + if err := ReadTLSIdentityFromKeyPair(&identity, params.PrivateKeyBytes, certs.TLS, certs.TLSCACerts); err != nil { + return nil, trace.Wrap(err) } identity.PublicKeyBytes = params.PublicKeyBytes diff --git a/tool/tbot/identity/kinds.go b/tool/tbot/identity/kinds.go index 5e98ff2da6905..6b9e3058c8d85 100644 --- a/tool/tbot/identity/kinds.go +++ b/tool/tbot/identity/kinds.go @@ -16,13 +16,6 @@ limitations under the License. package identity -import ( - "strings" - - "github.com/gravitational/trace" - "gopkg.in/yaml.v3" -) - // ArtifactKind is a type of identity artifact that can be stored and loaded. type ArtifactKind string @@ -31,42 +24,11 @@ const ( // generated. KindAlways ArtifactKind = "always" - // KindSSH identifies resources that should only be generated for SSH use. - KindSSH ArtifactKind = "ssh" - - // KindTLS identifies resources that should only be stored for TLS use. - KindTLS ArtifactKind = "tls" - // KindBotInternal identifies resources that should only be stored in the // bot's internal data directory. KindBotInternal ArtifactKind = "bot-internal" ) -// allConfigKinds is a list of all ArtifactKinds allowed in config files. -var allConfigKinds = []string{string(KindSSH), string(KindTLS)} - -func (ac *ArtifactKind) UnmarshalYAML(node *yaml.Node) error { - var kind string - if err := node.Decode(&kind); err != nil { - return err - } - - // Only TLS and SSH are configurable values. - switch kind { - case string(KindTLS): - *ac = KindTLS - case string(KindSSH): - *ac = KindSSH - default: - return trace.BadParameter( - "invalid kind %q, expected one of: %s", - kind, strings.Join(allConfigKinds, ", "), - ) - } - - return nil -} - // ContainsKind determines if a particular artifact kind is included in the // list of kinds. func ContainsKind(kind ArtifactKind, kinds []ArtifactKind) bool { @@ -82,5 +44,11 @@ func ContainsKind(kind ArtifactKind, kinds []ArtifactKind) bool { // BotKinds returns a list of all artifact kinds used internally by the bot. // End-user destinations may contain a different set of artifacts. func BotKinds() []ArtifactKind { - return []ArtifactKind{KindAlways, KindBotInternal, KindSSH, KindTLS} + return []ArtifactKind{KindAlways, KindBotInternal} +} + +// DestinationKinds returns a list of all artifact kinds that should be written +// to end-user destinations. +func DestinationKinds() []ArtifactKind { + return []ArtifactKind{KindAlways} } diff --git a/tool/tbot/init.go b/tool/tbot/init.go index 8a78090190ef8..3773670e258cb 100644 --- a/tool/tbot/init.go +++ b/tool/tbot/init.go @@ -47,7 +47,7 @@ func getInitArtifacts(destination *config.DestinationConfig) (map[string]bool, e // Collect all base artifacts and filter for the destination. for _, artifact := range identity.GetArtifacts() { - if artifact.Matches(destination.Kinds...) { + if artifact.Matches(identity.DestinationKinds()...) { toCreate[artifact.Key] = false } } @@ -424,9 +424,14 @@ func onInit(botConfig *config.BotConfig, cf *config.CLIConf) error { log.Infof("Initializing destination: %s", destImpl) + subdirs, err := destination.ListSubdirectories() + if err != nil { + return trace.Wrap(err) + } + // Create the directory if needed. We haven't checked directory ownership, // but it will fail when the ACLs are created if anything is misconfigured. - if err := destDir.Init(); err != nil { + if err := destDir.Init(subdirs); err != nil { return trace.Wrap(err) } diff --git a/tool/tbot/init_test.go b/tool/tbot/init_test.go index d154e0cb39480..d0056f3641210 100644 --- a/tool/tbot/init_test.go +++ b/tool/tbot/init_test.go @@ -136,7 +136,7 @@ func validateFileDestination(t *testing.T, dest *config.DestinationConfig) *conf require.True(t, ok) for _, art := range identity.GetArtifacts() { - if !art.Matches(dest.Kinds...) { + if !art.Matches(identity.DestinationKinds()...) { continue } @@ -214,7 +214,7 @@ func TestInitMaybeACLs(t *testing.T) { // If we expect ACLs, verify them. if expectACLs { - require.NoError(t, destDir.Verify(identity.ListKeys(cfg.Destinations[0].Kinds...))) + require.NoError(t, destDir.Verify(identity.ListKeys(identity.DestinationKinds()...))) } else { t.Logf("Skipping ACL check on %q as they should not be supported.", dir) } diff --git a/tool/tbot/main.go b/tool/tbot/main.go index c66a0b975c670..010a273db221c 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -276,7 +276,8 @@ func checkDestinations(cfg *config.BotConfig) error { // TODO: consider warning if ownership of all destintions is not expected. - if err := storage.Init(); err != nil { + // Note: no subdirs to init for bot's internal storage. + if err := storage.Init([]string{}); err != nil { return trace.Wrap(err) } @@ -286,7 +287,12 @@ func checkDestinations(cfg *config.BotConfig) error { return trace.Wrap(err) } - if err := destImpl.Init(); err != nil { + subdirs, err := dest.ListSubdirectories() + if err != nil { + return trace.Wrap(err) + } + + if err := destImpl.Init(subdirs); err != nil { return trace.Wrap(err) } } diff --git a/tool/tbot/renew.go b/tool/tbot/renew.go index 2933eb48dc251..1cc042241465a 100644 --- a/tool/tbot/renew.go +++ b/tool/tbot/renew.go @@ -24,7 +24,6 @@ import ( "strings" "time" - "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/client/proto" "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/defaults" @@ -133,51 +132,6 @@ func describeTLSIdentity(ident *identity.Identity) (string, error) { ), nil } -// describeSSHIdentity generates an informational message about the given -// SSH identity, appropriate for user-facing log messages. -func describeSSHIdentity(ident *identity.Identity) (string, error) { - cert := ident.SSHCert - if cert == nil { - return "", trace.BadParameter("attempted to describe SSH identity without SSH credentials") - } - - renewable := false - if _, ok := cert.Extensions[teleport.CertExtensionRenewable]; ok { - renewable = true - } - - disallowReissue := false - if _, ok := cert.Extensions[teleport.CertExtensionDisallowReissue]; ok { - disallowReissue = true - } - - var roles []string - if rolesStr, ok := cert.Extensions[teleport.CertExtensionTeleportRoles]; ok { - if actualRoles, err := services.UnmarshalCertRoles(rolesStr); err == nil { - roles = actualRoles - } - } - - var principals []string - for _, principal := range cert.ValidPrincipals { - if !strings.HasPrefix(principal, constants.NoLoginPrefix) { - principals = append(principals, principal) - } - } - - duration := time.Second * time.Duration(cert.ValidBefore-cert.ValidAfter) - return fmt.Sprintf( - "valid: after=%v, before=%v, duration=%s | kind=ssh, renewable=%v, disallow-reissue=%v, roles=%v, principals=%v", - time.Unix(int64(cert.ValidAfter), 0).Format(time.RFC3339), - time.Unix(int64(cert.ValidBefore), 0).Format(time.RFC3339), - duration, - renewable, - disallowReissue, - roles, - principals, - ), nil -} - // identityConfigurator is a function that alters a cert request type identityConfigurator = func(req *proto.UserCertsRequest) @@ -258,7 +212,7 @@ func generateIdentity( newIdentity, err := identity.ReadIdentityFromStore(&identity.LoadIdentityParams{ PrivateKeyBytes: privateKey, PublicKeyBytes: publicKey, - }, certs, destCfg.Kinds...) + }, certs, identity.DestinationKinds()...) if err != nil { return nil, trace.Wrap(err) } @@ -536,7 +490,7 @@ func renew( // Check the ACLs. We can't fix them, but we can warn if they're // misconfigured. We'll need to precompute a list of keys to check. // Note: This may only log a warning, depending on configuration. - if err := destImpl.Verify(identity.ListKeys(dest.Kinds...)); err != nil { + if err := destImpl.Verify(identity.ListKeys(identity.DestinationKinds()...)); err != nil { return nil, nil, trace.Wrap(err) } @@ -553,22 +507,14 @@ func renew( return nil, nil, trace.Wrap(err, "Failed to generate impersonated certs for %s: %+v", destImpl, err) } - var impersonatedIdentStr string - if dest.ContainsKind(identity.KindTLS) { - impersonatedIdentStr, err = describeTLSIdentity(impersonatedIdent) - if err != nil { - return nil, nil, trace.Wrap(err, "could not describe impersonated certs for destination %s", destImpl) - } - } else { - // Note: kinds must contain at least 1 of TLS or SSH - impersonatedIdentStr, err = describeSSHIdentity(impersonatedIdent) - if err != nil { - return nil, nil, trace.Wrap(err, "could not describe impersonated certs for destination %s", destImpl) - } + impersonatedIdentStr, err := describeTLSIdentity(impersonatedIdent) + if err != nil { + return nil, nil, trace.Wrap(err, "could not describe impersonated certs for destination %s", destImpl) } + log.Infof("Successfully renewed impersonated certificates for %s, %s", destImpl, impersonatedIdentStr) - if err := identity.SaveIdentity(impersonatedIdent, destImpl, dest.Kinds...); err != nil { + if err := identity.SaveIdentity(impersonatedIdent, destImpl, identity.DestinationKinds()...); err != nil { return nil, nil, trace.Wrap(err, "failed to save impersonated identity to destination %s", destImpl) } diff --git a/tool/tbot/renew_test.go b/tool/tbot/renew_test.go index 3965ce3853d50..95813f602ddc6 100644 --- a/tool/tbot/renew_test.go +++ b/tool/tbot/renew_test.go @@ -26,7 +26,6 @@ import ( libconfig "github.com/gravitational/teleport/lib/config" "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/tool/tbot/config" - "github.com/gravitational/teleport/tool/tbot/identity" "github.com/gravitational/teleport/tool/tbot/testhelpers" "github.com/gravitational/trace" "github.com/stretchr/testify/require" @@ -120,7 +119,6 @@ func TestDatabaseRequest(t *testing.T) { botConfig := testhelpers.MakeMemoryBotConfig(t, fc, botParams) dest := botConfig.Destinations[0] - dest.Kinds = []identity.ArtifactKind{identity.KindSSH, identity.KindTLS} dest.Database = &config.DatabaseConfig{ Service: "foo", Database: "bar", diff --git a/tool/tbot/testhelpers/srv.go b/tool/tbot/testhelpers/srv.go index 4bddd31065571..bd16ac1856331 100644 --- a/tool/tbot/testhelpers/srv.go +++ b/tool/tbot/testhelpers/srv.go @@ -48,10 +48,12 @@ func DefaultConfig(t *testing.T) *config.FileConfig { }, Proxy: config.Proxy{ Service: config.Service{ - EnabledFlag: "true", + EnabledFlag: "true", + ListenAddress: mustGetFreeLocalListenerAddr(t), }, - WebAddr: mustGetFreeLocalListenerAddr(t), - TunAddr: mustGetFreeLocalListenerAddr(t), + WebAddr: mustGetFreeLocalListenerAddr(t), + TunAddr: mustGetFreeLocalListenerAddr(t), + PublicAddr: []string{"proxy.example.com"}, }, Auth: config.Auth{ Service: config.Service{