diff --git a/api/client/credentials.go b/api/client/credentials.go index f9f3b67cc45d1..b38a1f999c066 100644 --- a/api/client/credentials.go +++ b/api/client/credentials.go @@ -662,3 +662,62 @@ func (d *DynamicIdentityFileCreds) Expiry() (time.Time, bool) { return x509Cert.NotAfter, true } + +// KeyPair returns a Credential give a TLS key, certificate and CA certificates PEM-encoded. +// It behaves live LoadKeyPair except it doesn't read the TLS material from a file. +// This is useful when key and certs are not on the disk (e.g. environment variables). +// This should be preferred over manually building a tls.Config and calling LoadTLS +// as Credentials returned by KeyPair can report their expiry, which allows to warn +// the user in case of expired certificates. +func KeyPair(certPEM, keyPEM, caPEM []byte) (Credentials, error) { + if len(certPEM) == 0 { + return nil, trace.BadParameter("missing certificate PEM data") + } + if len(keyPEM) == 0 { + return nil, trace.BadParameter("missing private key PEM data") + } + return &staticKeypairCreds{ + certPEM: certPEM, + keyPEM: keyPEM, + caPEM: caPEM, + }, nil +} + +// staticKeypairCreds uses keypair certificates to provide client credentials. +type staticKeypairCreds struct { + certPEM []byte + keyPEM []byte + caPEM []byte +} + +// TLSConfig returns TLS configuration. +func (c *staticKeypairCreds) TLSConfig() (*tls.Config, error) { + cert, err := keys.X509KeyPair(c.certPEM, c.keyPEM) + if err != nil { + return nil, trace.Wrap(err) + } + + pool := x509.NewCertPool() + if ok := pool.AppendCertsFromPEM(c.caPEM); !ok { + return nil, trace.BadParameter("invalid TLS CA cert PEM") + } + + return configureTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: pool, + }), nil +} + +// SSHClientConfig returns SSH configuration. +func (c *staticKeypairCreds) SSHClientConfig() (*ssh.ClientConfig, error) { + return nil, trace.NotImplemented("no ssh config") +} + +// Expiry returns the credential expiry. +func (c *staticKeypairCreds) Expiry() (time.Time, bool) { + cert, _, err := keys.X509Certificate(c.certPEM) + if err != nil { + return time.Time{}, false + } + return cert.NotAfter, true +} diff --git a/api/client/credentials_test.go b/api/client/credentials_test.go index 896a873c19811..63a28889e0f0b 100644 --- a/api/client/credentials_test.go +++ b/api/client/credentials_test.go @@ -218,6 +218,32 @@ func TestLoadKeyPair(t *testing.T) { require.False(t, ok, "expiry should be unknown on a broken credential") } +func TestKeyPair(t *testing.T) { + t.Parallel() + + // Load expected tls.Config. + expectedTLSConfig := getExpectedTLSConfig(t) + + // Load key pair from disk. + creds, err := KeyPair(tlsCert, keyPEM, tlsCACert) + require.NoError(t, err) + + // Build tls.Config and compare to expected tls.Config. + tlsConfig, err := creds.TLSConfig() + require.NoError(t, err) + requireEqualTLSConfig(t, expectedTLSConfig, tlsConfig) + + // Load invalid keypairs. + invalidIdentityCreds, err := KeyPair([]byte("invalid_cert"), []byte("invalid_key"), []byte("invalid_ca_cert")) + require.NoError(t, err) + _, err = invalidIdentityCreds.TLSConfig() + require.Error(t, err) + + // Load missing keypairs + _, err = KeyPair(nil, nil, nil) + require.Error(t, err) +} + func TestLoadProfile(t *testing.T) { t.Parallel() profileName := "proxy.example.com" diff --git a/api/constants/constants.go b/api/constants/constants.go index 07524dd22c685..a504937659a5b 100644 --- a/api/constants/constants.go +++ b/api/constants/constants.go @@ -461,3 +461,50 @@ const ( // Multiple decisions can be sent for the same request if the policy requires it. FileTransferDecision string = "file-transfer-decision@goteleport.com" ) + +// Terraform provider environment variable names. +// This is mainly used by the Terraform provider and the `tctl terraform` command. +const ( + // EnvVarTerraformAddress is the environment variable configuring the Teleport address the Terraform provider connects to. + EnvVarTerraformAddress = "TF_TELEPORT_ADDR" + // EnvVarTerraformCertificates is the environment variable configuring the path the Terraform provider loads its + // client certificates from. This only works for direct auth joining. + EnvVarTerraformCertificates = "TF_TELEPORT_CERT" + // EnvVarTerraformCertificatesBase64 is the environment variable configuring the client certificates used by the + // Terraform provider. This only works for direct auth joining. + EnvVarTerraformCertificatesBase64 = "TF_TELEPORT_CERT_BASE64" + // EnvVarTerraformKey is the environment variable configuring the path the Terraform provider loads its + // client key from. This only works for direct auth joining. + EnvVarTerraformKey = "TF_TELEPORT_KEY" + // EnvVarTerraformKeyBase64 is the environment variable configuring the client key used by the + // Terraform provider. This only works for direct auth joining. + EnvVarTerraformKeyBase64 = "TF_TELEPORT_KEY_BASE64" + // EnvVarTerraformRootCertificates is the environment variable configuring the path the Terraform provider loads its + // trusted CA certificates from. This only works for direct auth joining. + EnvVarTerraformRootCertificates = "TF_TELEPORT_ROOT_CA" + // EnvVarTerraformRootCertificatesBase64 is the environment variable configuring the CA certificates trusted by the + // Terraform provider. This only works for direct auth joining. + EnvVarTerraformRootCertificatesBase64 = "TF_TELEPORT_CA_BASE64" + // EnvVarTerraformProfileName is the environment variable containing name of the profile used by the Terraform provider. + EnvVarTerraformProfileName = "TF_TELEPORT_PROFILE_NAME" + // EnvVarTerraformProfilePath is the environment variable containing the profile directory used by the Terraform provider. + EnvVarTerraformProfilePath = "TF_TELEPORT_PROFILE_PATH" + // EnvVarTerraformIdentityFilePath is the environment variable containing the path to the identity file used by the provider. + EnvVarTerraformIdentityFilePath = "TF_TELEPORT_IDENTITY_FILE_PATH" + // EnvVarTerraformIdentityFile is the environment variable containing the identity file used by the Terraform provider. + EnvVarTerraformIdentityFile = "TF_TELEPORT_IDENTITY_FILE" + // EnvVarTerraformIdentityFileBase64 is the environment variable containing the base64-encoded identity file used by the Terraform provider. + EnvVarTerraformIdentityFileBase64 = "TF_TELEPORT_IDENTITY_FILE_BASE64" + // EnvVarTerraformRetryBaseDuration is the environment variable configuring the base duration between two Terraform provider retries. + EnvVarTerraformRetryBaseDuration = "TF_TELEPORT_RETRY_BASE_DURATION" + // EnvVarTerraformRetryCapDuration is the environment variable configuring the maximum duration between two Terraform provider retries. + EnvVarTerraformRetryCapDuration = "TF_TELEPORT_RETRY_CAP_DURATION" + // EnvVarTerraformRetryMaxTries is the environment variable configuring the maximum number of Terraform provider retries. + EnvVarTerraformRetryMaxTries = "TF_TELEPORT_RETRY_MAX_TRIES" + // EnvVarTerraformDialTimeoutDuration is the environment variable configuring the Terraform provider dial timeout. + EnvVarTerraformDialTimeoutDuration = "TF_TELEPORT_DIAL_TIMEOUT_DURATION" + // EnvVarTerraformJoinMethod is the environment variable configuring the Terraform provider native MachineID join method. + EnvVarTerraformJoinMethod = "TF_TELEPORT_JOIN_METHOD" + // EnvVarTerraformJoinToken is the environment variable configuring the Terraform provider native MachineID join token. + EnvVarTerraformJoinToken = "TF_TELEPORT_JOIN_TOKEN" +) diff --git a/constants.go b/constants.go index 9b455a9fde654..57d5036a3a41b 100644 --- a/constants.go +++ b/constants.go @@ -668,6 +668,10 @@ const ( // resources. PresetRequireTrustedDeviceRoleName = "require-trusted-device" + // PresetTerraformProviderRoleName is a name of a default role that allows the Terraform provider + // to configure all its supported Teleport resources. + PresetTerraformProviderRoleName = "terraform-provider" + // SystemAutomaticAccessApprovalRoleName names a preset role that may // automatically approve any Role Access Request SystemAutomaticAccessApprovalRoleName = "@teleport-access-approver" diff --git a/docs/pages/reference/terraform-provider.mdx b/docs/pages/reference/terraform-provider.mdx index 55638caba7189..3ef34a8dbb978 100644 --- a/docs/pages/reference/terraform-provider.mdx +++ b/docs/pages/reference/terraform-provider.mdx @@ -131,13 +131,15 @@ This auth method has the following limitations: ### Optional -- `addr` (String) host:port where Teleport Auth Service is running. This can also be set with the environment variable `TF_TELEPORT_ADDR`. +- `addr` (String) host:port of the Teleport address. This can be the Teleport Proxy Service address (port 443 or 4080) or the Teleport Auth Service address (port 3025). This can also be set with the environment variable `TF_TELEPORT_ADDR`. - `cert_base64` (String) Base64 encoded TLS auth certificate. This can also be set with the environment variable `TF_TELEPORT_CERT_BASE64`. - `cert_path` (String) Path to Teleport auth certificate file. This can also be set with the environment variable `TF_TELEPORT_CERT`. - `dial_timeout_duration` (String) DialTimeout sets timeout when trying to connect to the server. This can also be set with the environment variable `TF_TELEPORT_DIAL_TIMEOUT_DURATION`. - `identity_file` (String, Sensitive) Teleport identity file content. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE`. - `identity_file_base64` (String, Sensitive) Teleport identity file content base64 encoded. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE_BASE64`. - `identity_file_path` (String) Teleport identity file path. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE_PATH`. +- `join_method` (String) Enables the native Terraform MachineID support. When set, Terraform uses MachineID to securely join the Teleport cluster and obtain credentials. See [the join method reference](./join-methods.mdx) for possible values, you must use [a delegated join method](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `TF_TELEPORT_JOIN_METHOD`. +- `join_token` (String) Name of the token used for the native MachineID joining. This value is not sensitive for [delegated join methods](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `TF_TELEPORT_JOIN_TOKEN`. - `key_base64` (String, Sensitive) Base64 encoded TLS auth key. This can also be set with the environment variable `TF_TELEPORT_KEY_BASE64`. - `key_path` (String) Path to Teleport auth key file. This can also be set with the environment variable `TF_TELEPORT_KEY`. - `profile_dir` (String) Teleport profile path. This can also be set with the environment variable `TF_TELEPORT_PROFILE_PATH`. diff --git a/integration/helpers/helpers.go b/integration/helpers/helpers.go index 63525f86b319f..9c9bd8e231ff3 100644 --- a/integration/helpers/helpers.go +++ b/integration/helpers/helpers.go @@ -189,7 +189,7 @@ func CloseAgent(teleAgent *teleagent.AgentServer, socketDirPath string) error { return nil } -func MustCreateUserIdentityFile(t *testing.T, tc *TeleInstance, username string, ttl time.Duration) string { +func MustCreateUserKey(t *testing.T, tc *TeleInstance, username string, ttl time.Duration) *client.Key { key, err := client.GenerateRSAKey() require.NoError(t, err) key.ClusterName = tc.Secrets.SiteName @@ -209,9 +209,14 @@ func MustCreateUserIdentityFile(t *testing.T, tc *TeleInstance, username string, hostCAs, err := tc.Process.GetAuthServer().GetCertAuthorities(context.Background(), types.HostCA, false) require.NoError(t, err) key.TrustedCerts = authclient.AuthoritiesToTrustedCerts(hostCAs) + return key +} + +func MustCreateUserIdentityFile(t *testing.T, tc *TeleInstance, username string, ttl time.Duration) string { + key := MustCreateUserKey(t, tc, username, ttl) idPath := filepath.Join(t.TempDir(), "user_identity") - _, err = identityfile.Write(context.Background(), identityfile.WriteConfig{ + _, err := identityfile.Write(context.Background(), identityfile.WriteConfig{ OutputPath: idPath, Key: key, Format: identityfile.FormatFile, diff --git a/integration/tctl_terraform_env_test.go b/integration/tctl_terraform_env_test.go new file mode 100644 index 0000000000000..9ebea1c95bc35 --- /dev/null +++ b/integration/tctl_terraform_env_test.go @@ -0,0 +1,349 @@ +/* + * 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 integration + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/breaker" + "github.com/gravitational/teleport/api/client" + "github.com/gravitational/teleport/api/client/webclient" + "github.com/gravitational/teleport/api/constants" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integration/helpers" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/reversetunnelclient" + "github.com/gravitational/teleport/lib/service/servicecfg" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/tool/tctl/common" +) + +// TestTCTLTerraformCommand_ProxyJoin validates that the command `tctl terraform env` can run against a Teleport Proxy +// service and generates valid credentials Terraform can use to connect to Teleport. +func TestTCTLTerraformCommand_ProxyJoin(t *testing.T) { + testDir := t.TempDir() + + // Test setup: creating a teleport instance running auth and proxy + clusterName := "root.example.com" + cfg := helpers.InstanceConfig{ + ClusterName: clusterName, + HostID: uuid.New().String(), + NodeName: helpers.Loopback, + Log: utils.NewLoggerForTests(), + } + cfg.Listeners = helpers.SingleProxyPortSetup(t, &cfg.Fds) + rc := helpers.NewInstance(t, cfg) + + rcConf := servicecfg.MakeDefaultConfig() + rcConf.DataDir = filepath.Join(testDir, "data") + rcConf.Auth.Enabled = true + rcConf.Proxy.Enabled = true + rcConf.SSH.Enabled = false + rcConf.Proxy.DisableWebInterface = true + rcConf.Version = "v3" + rcConf.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex) + + testUsername := "test-user" + createTCTLTerraformUserAndRole(t, testUsername, rc) + + // Test setup: starting the Teleport instance + err := rc.CreateEx(t, nil, rcConf) + require.NoError(t, err) + + err = rc.Start() + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, rc.StopAll()) + }) + + // Test setup: obtaining and authclient connected via the proxy + clt := getAuthClientForProxy(t, rc, testUsername, time.Hour) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + _, err = clt.Ping(ctx) + require.NoError(t, err) + + addr, err := rc.Process.ProxyWebAddr() + require.NoError(t, err) + + // Test execution, running the tctl command + tctlCfg := &servicecfg.Config{} + err = tctlCfg.SetAuthServerAddresses([]utils.NetAddr{*addr}) + require.NoError(t, err) + tctlCommand := common.TerraformCommand{} + + app := kingpin.New("test", "test") + tctlCommand.Initialize(app, tctlCfg) + _, err = app.Parse([]string{"terraform", "env"}) + require.NoError(t, err) + // Create io buffer writer + stdout := &bytes.Buffer{} + + err = tctlCommand.RunEnvCommand(ctx, clt, stdout, os.Stderr) + require.NoError(t, err) + + vars := parseExportedEnvVars(t, stdout) + require.Contains(t, vars, constants.EnvVarTerraformAddress) + require.Contains(t, vars, constants.EnvVarTerraformIdentityFileBase64) + + // Test validation: connect with the credentials in env vars and do a ping + require.Equal(t, addr.String(), vars[constants.EnvVarTerraformAddress]) + + connectWithCredentialsFromVars(t, vars, clt) +} + +// TestTCTLTerraformCommand_AuthJoin validates that the command `tctl terraform env` can run against a Teleport Auth +// service and generates valid credentials Terraform can use to connect to Teleport. +func TestTCTLTerraformCommand_AuthJoin(t *testing.T) { + t.Parallel() + testDir := t.TempDir() + + // Test setup: creating a teleport instance running auth and proxy + clusterName := "root.example.com" + cfg := helpers.InstanceConfig{ + ClusterName: clusterName, + HostID: uuid.New().String(), + NodeName: helpers.Loopback, + Log: utils.NewLoggerForTests(), + } + cfg.Listeners = helpers.SingleProxyPortSetup(t, &cfg.Fds) + rc := helpers.NewInstance(t, cfg) + + rcConf := servicecfg.MakeDefaultConfig() + rcConf.DataDir = filepath.Join(testDir, "data") + rcConf.Auth.Enabled = true + rcConf.Proxy.Enabled = false + rcConf.SSH.Enabled = false + rcConf.Version = "v3" + + testUsername := "test-user" + createTCTLTerraformUserAndRole(t, testUsername, rc) + + // Test setup: starting the Teleport instance + err := rc.CreateEx(t, nil, rcConf) + require.NoError(t, err) + + err = rc.Start() + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, rc.StopAll()) + }) + + // Test setup: obtaining and authclient connected via the proxy + clt := getAuthClientForAuth(t, rc, testUsername, time.Hour) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + _, err = clt.Ping(ctx) + require.NoError(t, err) + + addr, err := rc.Process.AuthAddr() + require.NoError(t, err) + + // Test execution, running the tctl command + tctlCfg := &servicecfg.Config{} + err = tctlCfg.SetAuthServerAddresses([]utils.NetAddr{*addr}) + require.NoError(t, err) + tctlCommand := common.TerraformCommand{} + + app := kingpin.New("test", "test") + tctlCommand.Initialize(app, tctlCfg) + _, err = app.Parse([]string{"terraform", "env"}) + require.NoError(t, err) + // Create io buffer writer + stdout := &bytes.Buffer{} + + err = tctlCommand.RunEnvCommand(ctx, clt, stdout, os.Stderr) + require.NoError(t, err) + + vars := parseExportedEnvVars(t, stdout) + require.Contains(t, vars, constants.EnvVarTerraformAddress) + require.Contains(t, vars, constants.EnvVarTerraformIdentityFileBase64) + + // Test validation: connect with the credentials in env vars and do a ping + require.Equal(t, addr.String(), vars[constants.EnvVarTerraformAddress]) + + connectWithCredentialsFromVars(t, vars, clt) +} + +func createTCTLTerraformUserAndRole(t *testing.T, username string, instance *helpers.TeleInstance) { + // Test setup: creating a test user and its role + role, err := types.NewRole("test-role", types.RoleSpecV6{ + Options: types.RoleOptions{}, + Allow: types.RoleConditions{ + Rules: []types.Rule{ + { + Resources: []string{types.KindToken, types.KindRole, types.KindBot}, + Verbs: []string{types.VerbRead, types.VerbCreate, types.VerbList, types.VerbUpdate}, + }, + }, + }, + }) + require.NoError(t, err) + + instance.AddUserWithRole(username, role) +} + +// getAuthCLientForProxy builds an authclient.CLient connecting to the auth through the proxy +// (with a web client resolver hitting /v1/wenapi/ping and a tunnel auth dialer reaching the auth through the proxy). +// For the tests, the client is configured to trust the proxy TLS certs on first connection. +func getAuthClientForProxy(t *testing.T, tc *helpers.TeleInstance, username string, ttl time.Duration) *authclient.Client { + // Get TLS and SSH material + key := helpers.MustCreateUserKey(t, tc, username, ttl) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + tlsConfig, err := key.TeleportClientTLSConfig(nil, []string{tc.Config.Auth.ClusterName.GetClusterName()}) + require.NoError(t, err) + tlsConfig.InsecureSkipVerify = true + proxyAddr, err := tc.Process.ProxyWebAddr() + require.NoError(t, err) + sshConfig, err := key.ProxyClientSSHConfig(proxyAddr.Host()) + require.NoError(t, err) + + // Build auth client configuration + authAddr, err := tc.Process.AuthAddr() + require.NoError(t, err) + clientConfig := &authclient.Config{ + TLS: tlsConfig, + SSH: sshConfig, + AuthServers: []utils.NetAddr{*authAddr}, + Log: utils.NewLoggerForTests(), + CircuitBreakerConfig: breaker.Config{}, + DialTimeout: 0, + DialOpts: nil, + // Insecure: true, + ProxyDialer: nil, + } + + // Configure the resolver and dialer to connect to the auth via a proxy + resolver, err := reversetunnelclient.CachingResolver( + ctx, + reversetunnelclient.WebClientResolver(&webclient.Config{ + Context: ctx, + ProxyAddr: clientConfig.AuthServers[0].String(), + Insecure: clientConfig.Insecure, + Timeout: clientConfig.DialTimeout, + }), + nil /* clock */) + require.NoError(t, err) + + dialer, err := reversetunnelclient.NewTunnelAuthDialer(reversetunnelclient.TunnelAuthDialerConfig{ + Resolver: resolver, + ClientConfig: clientConfig.SSH, + Log: clientConfig.Log, + InsecureSkipTLSVerify: clientConfig.Insecure, + ClusterCAs: clientConfig.TLS.RootCAs, + }) + require.NoError(t, err) + + clientConfig.ProxyDialer = dialer + + // Finally, build a client and connect + clt, err := authclient.Connect(ctx, clientConfig) + require.NoError(t, err) + return clt +} + +// getAuthClientForAuth builds an authclient.CLient connecting to the auth directly. +// This client only has TLSConfig set (as opposed to TLSConfig+SSHConfig). +func getAuthClientForAuth(t *testing.T, tc *helpers.TeleInstance, username string, ttl time.Duration) *authclient.Client { + // Get TLS and SSH material + key := helpers.MustCreateUserKey(t, tc, username, ttl) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + tlsConfig, err := key.TeleportClientTLSConfig(nil, []string{tc.Config.Auth.ClusterName.GetClusterName()}) + require.NoError(t, err) + + // Build auth client configuration + authAddr, err := tc.Process.AuthAddr() + require.NoError(t, err) + clientConfig := &authclient.Config{ + TLS: tlsConfig, + AuthServers: []utils.NetAddr{*authAddr}, + Log: utils.NewLoggerForTests(), + CircuitBreakerConfig: breaker.Config{}, + DialTimeout: 0, + DialOpts: nil, + ProxyDialer: nil, + } + + // Build the client and connect + clt, err := authclient.Connect(ctx, clientConfig) + require.NoError(t, err) + return clt +} + +// parseExportedEnvVars parses a buffer corresponding to the program's stdout and returns a map {env: value} +// of the exported variables. The buffer content should looks like: +// +// export VAR1="VALUE1" +// export VAR2="VALUE2" +// # this is a comment +func parseExportedEnvVars(t *testing.T, stdout *bytes.Buffer) map[string]string { + // Test validation: parse the output and extract exported envs + vars := map[string]string{} + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + if line[0] == '#' { + continue + } + require.True(t, strings.HasPrefix(line, "export ")) + parts := strings.Split(line, "=") + env := strings.TrimSpace(parts[0][7:]) + value := strings.Trim(strings.Join(parts[1:], "="), `"' `) + require.NotEmpty(t, env) + require.NotEmpty(t, value) + vars[env] = value + } + return vars +} + +// connectWithCredentialsFromVars takes the environment variables exported by the `tctl terraform env` command, +// builds a Teleport client from them, and validates it can ping the cluster. +func connectWithCredentialsFromVars(t *testing.T, vars map[string]string, clt *authclient.Client) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + identity, err := base64.StdEncoding.DecodeString(vars[constants.EnvVarTerraformIdentityFileBase64]) + require.NoError(t, err) + creds := client.LoadIdentityFileFromString(string(identity)) + require.NotNil(t, creds) + botClt, err := client.New(ctx, client.Config{ + Addrs: []string{vars[constants.EnvVarTerraformAddress]}, + Credentials: []client.Credentials{creds}, + InsecureAddressDiscovery: clt.Config().InsecureSkipVerify, + Context: ctx, + }) + require.NoError(t, err) + _, err = botClt.Ping(ctx) + require.NoError(t, err) +} diff --git a/integrations/lib/embeddedtbot/bot.go b/integrations/lib/embeddedtbot/bot.go index 9573ab52c99e0..e693b40793fe5 100644 --- a/integrations/lib/embeddedtbot/bot.go +++ b/integrations/lib/embeddedtbot/bot.go @@ -136,7 +136,7 @@ func (b *EmbeddedBot) Start(ctx context.Context) error { } } -func (b *EmbeddedBot) waitForClient(ctx context.Context, deadline time.Duration) (*client.Client, error) { +func (b *EmbeddedBot) waitForCredentials(ctx context.Context, deadline time.Duration) (client.Credentials, error) { waitCtx, cancel := context.WithTimeout(ctx, deadline) defer cancel() @@ -148,20 +148,32 @@ func (b *EmbeddedBot) waitForClient(ctx context.Context, deadline time.Duration) log.Infof("credential ready") } - c, err := b.buildClient(ctx) - return c, trace.Wrap(err) - + return b.credential, nil } // StartAndWaitForClient starts the EmbeddedBot and waits for a client to be available. -// This is the proper way of starting the EmbeddedBot. It returns an error if the -// EmbeddedBot is not able to get a certificate before the deadline. +// It returns an error if the EmbeddedBot is not able to get a certificate before the deadline. +// If you need a client.Credentials instead, you can use StartAndWaitForCredentials. func (b *EmbeddedBot) StartAndWaitForClient(ctx context.Context, deadline time.Duration) (*client.Client, error) { b.start(ctx) - c, err := b.waitForClient(ctx, deadline) + _, err := b.waitForCredentials(ctx, deadline) + if err != nil { + return nil, trace.Wrap(err) + } + + c, err := b.buildClient(ctx) return c, trace.Wrap(err) } +// StartAndWaitForCredentials starts the EmbeddedBot and waits for credentials to become ready. +// It returns an error if the EmbeddedBot is not able to get a certificate before the deadline. +// If you need a client.Client instead, you can use StartAndWaitForClient. +func (b *EmbeddedBot) StartAndWaitForCredentials(ctx context.Context, deadline time.Duration) (client.Credentials, error) { + b.start(ctx) + creds, err := b.waitForCredentials(ctx, deadline) + return creds, trace.Wrap(err) +} + // buildClient reads tbot's memory disttination, retrieves the certificates // and builds a new Teleport client using those certs. func (b *EmbeddedBot) buildClient(ctx context.Context) (*client.Client, error) { diff --git a/integrations/lib/embeddedtbot/bot_test.go b/integrations/lib/embeddedtbot/bot_test.go index 18075e81cf945..ca4bc9480af43 100644 --- a/integrations/lib/embeddedtbot/bot_test.go +++ b/integrations/lib/embeddedtbot/bot_test.go @@ -134,7 +134,7 @@ func TestBotJoinAuth(t *testing.T) { require.NoError(t, err) require.Equal(t, clusterName, pong.ClusterName) - botClient, err := bot.waitForClient(ctx, 10*time.Second) + botClient, err := bot.StartAndWaitForClient(ctx, 10*time.Second) require.NoError(t, err) botPong, err := botClient.Ping(ctx) require.NoError(t, err) diff --git a/integrations/terraform/go.mod b/integrations/terraform/go.mod index a5919a5630ee6..42cac8463f60c 100644 --- a/integrations/terraform/go.mod +++ b/integrations/terraform/go.mod @@ -18,12 +18,13 @@ require ( github.com/gravitational/trace v1.4.0 github.com/hashicorp/terraform-plugin-framework v0.10.0 github.com/hashicorp/terraform-plugin-go v0.18.0 + github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.10.1 github.com/jonboulle/clockwork v0.4.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 google.golang.org/grpc v1.64.1 - google.golang.org/protobuf v1.34.1 + google.golang.org/protobuf v1.34.2 ) require ( @@ -137,6 +138,8 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 // indirect github.com/emicklei/go-restful/v3 v3.11.3 // indirect + github.com/envoyproxy/go-control-plane v0.12.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.0.4 // indirect github.com/evanphx/json-patch v5.9.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect @@ -150,6 +153,7 @@ require ( github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect @@ -207,7 +211,6 @@ require ( github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.21.0 // indirect github.com/hashicorp/terraform-json v0.22.1 // indirect - github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.1 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d // indirect @@ -220,6 +223,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jmoiron/sqlx v1.3.5 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect @@ -233,6 +237,7 @@ require ( github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lib/pq v1.10.9 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mailgun/holster/v3 v3.16.2 // indirect github.com/mailgun/minheap v0.0.0-20170619185613-3dbe6c6bf55f // indirect github.com/mailgun/timetools v0.0.0-20170619190023-f3a7b8ffff47 // indirect @@ -271,6 +276,7 @@ require ( github.com/pkg/sftp v1.13.6 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/posener/complete v1.2.3 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/pquerna/cachecontrol v0.1.0 // indirect github.com/pquerna/otp v1.4.0 // indirect github.com/prometheus/client_golang v1.19.0 // indirect @@ -285,6 +291,8 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/schollz/progressbar/v3 v3.14.2 // indirect github.com/scim2/filter-parser/v2 v2.2.0 // indirect + github.com/shirou/gopsutil/v4 v4.24.6 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sijms/go-ora/v2 v2.8.10 // indirect github.com/spf13/cast v1.6.0 // indirect @@ -293,6 +301,8 @@ require ( github.com/spiffe/go-spiffe/v2 v2.2.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/thales-e-security/pool v0.0.2 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect @@ -307,6 +317,7 @@ require ( github.com/xlab/treeprint v1.2.0 // indirect github.com/yuin/goldmark v1.7.4 // indirect github.com/yuin/goldmark-meta v1.1.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zeebo/errs v1.3.0 // indirect github.com/zmap/zcrypto v0.0.0-20231219022726-a1f61fb1661c // indirect diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum index 45d48438228b8..6392da56d26e6 100644 --- a/integrations/terraform/go.sum +++ b/integrations/terraform/go.sum @@ -1082,6 +1082,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -1507,6 +1509,10 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -1558,6 +1564,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= @@ -1730,6 +1738,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc= @@ -1802,6 +1812,12 @@ github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNX github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shirou/gopsutil/v4 v4.24.6 h1:9qqCSYF2pgOU+t+NgJtp7Co5+5mHF/HyKBUckySQL64= +github.com/shirou/gopsutil/v4 v4.24.6/go.mod h1:aoebb2vxetJ/yIDZISmduFvVNPHqXQ9SEJwRXxkf0RA= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= @@ -1855,6 +1871,10 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/thales-e-security/pool v0.0.2 h1:RAPs4q2EbWsTit6tpzuvTFlgFRJ3S8Evf5gtvVDbmPg= github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -1909,6 +1929,8 @@ github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUei github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50 h1:hlE8//ciYMztlGpl/VA+Zm1AcTPHYkHJPbHqE6WJUXE= @@ -2250,6 +2272,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -2304,6 +2327,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -2433,6 +2457,8 @@ golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNq golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0= @@ -2714,8 +2740,8 @@ google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/integrations/terraform/provider/credentials.go b/integrations/terraform/provider/credentials.go new file mode 100644 index 0000000000000..e38eab6233c7e --- /dev/null +++ b/integrations/terraform/provider/credentials.go @@ -0,0 +1,570 @@ +/* +Copyright 2024 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 provider + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "strings" + "text/template" + "time" + + "github.com/gravitational/trace" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-log/tflog" + + "github.com/gravitational/teleport/api/client" + "github.com/gravitational/teleport/api/constants" + apitypes "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/lib/embeddedtbot" + tbotconfig "github.com/gravitational/teleport/lib/tbot/config" +) + +var supportedCredentialSources = CredentialSources{ + CredentialsFromNativeMachineID{}, + CredentialsFromKeyAndCertPath{}, + CredentialsFromKeyAndCertBase64{}, + CredentialsFromIdentityFilePath{}, + CredentialsFromIdentityFileString{}, + CredentialsFromIdentityFileBase64{}, + CredentialsFromProfile{}, +} + +// CredentialSources is a list of CredentialSource +type CredentialSources []CredentialSource + +// ActiveSources returns the list of active sources, and an error diagnostic if no source is active. +// The error diagnostic explains why every source is inactive. +func (s CredentialSources) ActiveSources(ctx context.Context, config providerData) (CredentialSources, diag.Diagnostics) { + var activeSources CredentialSources + inactiveReason := strings.Builder{} + for _, source := range s { + active, reason := source.IsActive(config) + logFields := map[string]interface{}{ + "source": source.Name(), + "active": active, + "reason": reason, + } + if !active { + tflog.Info(ctx, "credentials source is not active, skipping", logFields) + inactiveReason.WriteString(fmt.Sprintf(" - cannot read credentials %s because %s\n", source.Name(), reason)) + continue + } + tflog.Info(ctx, "credentials source is active", logFields) + activeSources = append(activeSources, source) + } + if len(activeSources) == 0 { + // TODO: make this a hard failure in v17 + // We currently try to load credentials from the user profile. + // As trying broken credentials takes 30 seconds this is a very bad UX and we should get rid of this. + // Credentials from profile are not passing MFA4Admin anyway. + summary := inactiveReason.String() + + "\nThe provider will fallback to your current local profile (this behavior is deprecated and will be removed in v17, you should specify the profile name or directory)." + return CredentialSources{CredentialsFromProfile{isDefault: true}}, diag.Diagnostics{diag.NewWarningDiagnostic( + "No active Teleport credentials source found", + summary, + )} + } + return activeSources, nil +} + +// BuildClient sequentially builds credentials for every source and tries to use them to connect to Teleport. +// Any CredentialSource failing to return a Credential and a tls.Config causes a hard failure. +// If we have a valid credential but cannot connect, we send a warning and continue with the next credential +// (this is for backward compatibility). +// Expired credentials are skipped for the sake of UX. This is the most common failure mode and we can +// return an error quickly instead of hanging for 30 whole seconds. +func (s CredentialSources) BuildClient(ctx context.Context, clientCfg client.Config, providerCfg providerData) (*client.Client, diag.Diagnostics) { + diags := diag.Diagnostics{} + for _, source := range s { + logFields := map[string]interface{}{ + "source": source.Name(), + } + tflog.Info(ctx, fmt.Sprintf("trying to build a client %s", source.Name()), logFields) + creds, err := source.Credentials(ctx, providerCfg) + if err != nil { + logFields["error"] = err.Error() + tflog.Error(ctx, "failed to obtain credential", logFields) + _, reason := source.IsActive(providerCfg) + diags.AddError( + fmt.Sprintf("Failed to obtain Teleport credentials %s", source.Name()), + brokenCredentialErrorSummary(source.Name(), reason, err), + ) + return nil, diags + } + + // Smoke test to see if the credential is valid + // This catches all the "file not found" issues and other broken credentials + // so we can turn them into a hard failure. + _, err = creds.TLSConfig() + if err != nil { + logFields["error"] = err.Error() + tflog.Error(ctx, "failed to get a TLSConfig from the credential", logFields) + _, reason := source.IsActive(providerCfg) + diags.AddError( + fmt.Sprintf("Invalid Teleport credentials %s", source.Name()), + brokenCredentialErrorSummary(source.Name(), reason, err), + ) + + return nil, diags + } + + now := time.Now() + if expiry, ok := client.Expiry(creds); ok && !expiry.IsZero() && expiry.Before(now) { + diags.AddWarning( + fmt.Sprintf("Teleport credentials %s are expired", source.Name()), + fmt.Sprintf(`The credentials %s are expired. Expiration is %q while current time is %q). You might need to refresh them. The provider will not attempt to use those credentials.`, + source.Name(), expiry.Local(), now.Local()), + ) + continue + } + + clientCfg.Credentials = []client.Credentials{creds} + // In case of connection failure, this takes 30 seconds to return, which is very, very long. + clt, err := client.New(ctx, clientCfg) + if err != nil { + logFields["error"] = err.Error() + tflog.Error(ctx, "failed to connect with the credential", logFields) + diags.AddWarning( + fmt.Sprintf("Failed to connect with credentials %s", source.Name()), + fmt.Sprintf("The client built from the credentials %s failed to connect to %q with the error: %s.", + source.Name(), clientCfg.Addrs[0], err, + )) + continue + } + // A client was successfully built + return clt, diags + } + // No client was built + diags.AddError("Impossible to build Teleport client", s.failedToBuildClientErrorSummary(clientCfg.Addrs[0])) + return nil, diags +} + +const failedToBuildClientErrorTemplate = `"Every credential source provided has failed. The Terraform provider cannot connect to the Teleport cluster '{{.Addr}}'. + +The provider tried building a client: +{{- range $_, $source := .Sources }} +- {{ $source }} +{{- end }} + +You can find more information about why each credential source failed in the Terraform warnings above this error.` + +// failedToBuildClientErrorSummary builds a user-friendly message explaining we failed to build a functional Teleport +// client and listing every connection method we tried. +func (s CredentialSources) failedToBuildClientErrorSummary(addr string) string { + var sources []string + for _, source := range s { + sources = append(sources, source.Name()) + } + + tpl := template.Must(template.New("failed-to-build-client-error-summary").Parse(failedToBuildClientErrorTemplate)) + values := struct { + Addr string + Sources []string + }{ + Addr: addr, + Sources: sources, + } + buffer := new(bytes.Buffer) + err := tpl.Execute(buffer, values) + if err != nil { + return "Failed to build error summary. This is a provider bug: " + err.Error() + } + return buffer.String() +} + +const brokenCredentialErrorTemplate = `The Terraform provider tried to build credentials {{ .Source }} but received the following error: + +{{ .Error }} + +The provider tried to use the credential source because {{ .Reason }}. You must either address the error or disable the credential source by removing its values.` + +// brokenCredentialErrorSummary returns a user-friendly message explaining why we failed to +func brokenCredentialErrorSummary(name, activeReason string, err error) string { + tpl := template.Must(template.New("broken-credential-error-summary").Parse(brokenCredentialErrorTemplate)) + values := struct { + Source string + Error string + Reason string + }{ + Source: name, + Error: err.Error(), + Reason: activeReason, + } + buffer := new(bytes.Buffer) + tplErr := tpl.Execute(buffer, values) + if tplErr != nil { + return fmt.Sprintf("Failed to build error '%s' summary. This is a provider bug: %s", err, tplErr) + } + return buffer.String() +} + +// CredentialSource is a potential way for the Terraform provider to obtain the +// client.Credentials needed to connect to the Teleport cluster. +// A CredentialSource is active if the user specified configuration specific to this source. +// Only active CredentialSources are considered by the Provider. +type CredentialSource interface { + Name() string + IsActive(providerData) (bool, string) + Credentials(context.Context, providerData) (client.Credentials, error) +} + +// CredentialsFromKeyAndCertPath builds credentials from key, cert and ca cert paths. +type CredentialsFromKeyAndCertPath struct{} + +// Name implements CredentialSource and returns the source name. +func (CredentialsFromKeyAndCertPath) Name() string { + return "from Key, Cert, and CA path" +} + +// IsActive implements CredentialSource and returns if the source is active and why. +func (CredentialsFromKeyAndCertPath) IsActive(config providerData) (bool, string) { + certPath := stringFromConfigOrEnv(config.CertPath, constants.EnvVarTerraformCertificates, "") + keyPath := stringFromConfigOrEnv(config.KeyPath, constants.EnvVarTerraformKey, "") + + // This method is active as soon as a cert or a key path are set. + active := certPath != "" || keyPath != "" + + return activeReason( + active, + attributeTerraformCertificates, attributeTerraformKey, + constants.EnvVarTerraformCertificates, constants.EnvVarTerraformKey, + ) +} + +// Credentials implements CredentialSource and returns a client.Credentials for the provider. +func (CredentialsFromKeyAndCertPath) Credentials(ctx context.Context, config providerData) (client.Credentials, error) { + certPath := stringFromConfigOrEnv(config.CertPath, constants.EnvVarTerraformCertificates, "") + keyPath := stringFromConfigOrEnv(config.KeyPath, constants.EnvVarTerraformKey, "") + caPath := stringFromConfigOrEnv(config.RootCaPath, constants.EnvVarTerraformRootCertificates, "") + + // Validate that we have all paths. + if certPath == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformCertificates, constants.EnvVarTerraformCertificates) + } + if keyPath == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformKey, constants.EnvVarTerraformKey) + } + if caPath == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformRootCertificates, constants.EnvVarTerraformRootCertificates) + } + + // Validate the files exist for a better UX? + + creds := client.LoadKeyPair(certPath, keyPath, caPath) + return creds, nil +} + +// CredentialsFromKeyAndCertBase64 builds credentials from key, cert, and CA cert base64. +type CredentialsFromKeyAndCertBase64 struct{} + +// Name implements CredentialSource and returns the source name. +func (CredentialsFromKeyAndCertBase64) Name() string { + return "from Key, Cert, and CA base64" +} + +// IsActive implements CredentialSource and returns if the source is active and why. +func (CredentialsFromKeyAndCertBase64) IsActive(config providerData) (bool, string) { + certBase64 := stringFromConfigOrEnv(config.CertBase64, constants.EnvVarTerraformCertificatesBase64, "") + keyBase64 := stringFromConfigOrEnv(config.KeyBase64, constants.EnvVarTerraformKeyBase64, "") + + // This method is active as soon as a cert or a key is passed. + active := certBase64 != "" || keyBase64 != "" + + return activeReason( + active, + attributeTerraformCertificatesBase64, attributeTerraformKeyBase64, + constants.EnvVarTerraformCertificatesBase64, constants.EnvVarTerraformKeyBase64, + ) +} + +// Credentials implements CredentialSource and returns a client.Credentials for the provider. +func (CredentialsFromKeyAndCertBase64) Credentials(ctx context.Context, config providerData) (client.Credentials, error) { + certBase64 := stringFromConfigOrEnv(config.CertBase64, constants.EnvVarTerraformCertificatesBase64, "") + keyBase64 := stringFromConfigOrEnv(config.KeyBase64, constants.EnvVarTerraformKeyBase64, "") + caBase64 := stringFromConfigOrEnv(config.RootCaBase64, constants.EnvVarTerraformRootCertificatesBase64, "") + + // Validate that we have all paths. + if certBase64 == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformCertificatesBase64, constants.EnvVarTerraformCertificatesBase64) + } + if keyBase64 == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformKeyBase64, constants.EnvVarTerraformKeyBase64) + } + if caBase64 == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformRootCertificatesBase64, constants.EnvVarTerraformRootCertificatesBase64) + } + + certPEM, err := base64.StdEncoding.DecodeString(certBase64) + if err != nil { + return nil, trace.Wrap(err, "failed to decode the certificate's base64 (standard b64 encoding)") + } + keyPEM, err := base64.StdEncoding.DecodeString(keyBase64) + if err != nil { + return nil, trace.Wrap(err, "failed to decode the key's base64 (standard b64 encoding)") + } + caPEM, err := base64.StdEncoding.DecodeString(caBase64) + if err != nil { + return nil, trace.Wrap(err, "failed to decode the CA's base64 (standard b64 encoding)") + } + + creds, err := client.KeyPair(certPEM, keyPEM, caPEM) + return creds, trace.Wrap(err, "failed to load credentials from the PEM-encoded key and certificate") +} + +// CredentialsFromIdentityFilePath builds credentials from an identity file path. +type CredentialsFromIdentityFilePath struct{} + +// Name implements CredentialSource and returns the source name. +func (CredentialsFromIdentityFilePath) Name() string { + return "from the identity file path" +} + +// IsActive implements CredentialSource and returns if the source is active and why. +func (CredentialsFromIdentityFilePath) IsActive(config providerData) (bool, string) { + identityFilePath := stringFromConfigOrEnv(config.IdentityFilePath, constants.EnvVarTerraformIdentityFilePath, "") + + active := identityFilePath != "" + + return activeReason( + active, + attributeTerraformIdentityFilePath, constants.EnvVarTerraformIdentityFilePath, + ) +} + +// Credentials implements CredentialSource and returns a client.Credentials for the provider. +func (CredentialsFromIdentityFilePath) Credentials(ctx context.Context, config providerData) (client.Credentials, error) { + identityFilePath := stringFromConfigOrEnv(config.IdentityFilePath, constants.EnvVarTerraformIdentityFilePath, "") + + return client.LoadIdentityFile(identityFilePath), nil +} + +// CredentialsFromIdentityFileString builds credentials from an identity file passed as a string. +type CredentialsFromIdentityFileString struct{} + +// Name implements CredentialSource and returns the source name. +func (CredentialsFromIdentityFileString) Name() string { + return "from the identity file (passed as a string)" +} + +// IsActive implements CredentialSource and returns if the source is active and why. +func (CredentialsFromIdentityFileString) IsActive(config providerData) (bool, string) { + identityFileString := stringFromConfigOrEnv(config.IdentityFile, constants.EnvVarTerraformIdentityFile, "") + + active := identityFileString != "" + + return activeReason( + active, + attributeTerraformIdentityFile, constants.EnvVarTerraformIdentityFile, + ) +} + +// Credentials implements CredentialSource and returns a client.Credentials for the provider. +func (CredentialsFromIdentityFileString) Credentials(ctx context.Context, config providerData) (client.Credentials, error) { + identityFileString := stringFromConfigOrEnv(config.IdentityFile, constants.EnvVarTerraformIdentityFile, "") + + return client.LoadIdentityFileFromString(identityFileString), nil +} + +// CredentialsFromIdentityFileBase64 builds credentials from an identity file passed as a base64-encoded string. +type CredentialsFromIdentityFileBase64 struct{} + +// Name implements CredentialSource and returns the source name. +func (CredentialsFromIdentityFileBase64) Name() string { + return "from the identity file (passed as a base64-encoded string)" +} + +// IsActive implements CredentialSource and returns if the source is active and why. +func (CredentialsFromIdentityFileBase64) IsActive(config providerData) (bool, string) { + identityFileBase64 := stringFromConfigOrEnv(config.IdentityFileBase64, constants.EnvVarTerraformIdentityFileBase64, "") + + // This method is active as soon as a cert or a key path are set. + active := identityFileBase64 != "" + + return activeReason( + active, + attributeTerraformIdentityFileBase64, constants.EnvVarTerraformIdentityFileBase64, + ) +} + +// Credentials implements CredentialSource and returns a client.Credentials for the provider. +func (CredentialsFromIdentityFileBase64) Credentials(ctx context.Context, config providerData) (client.Credentials, error) { + identityFileBase64 := stringFromConfigOrEnv(config.IdentityFileBase64, constants.EnvVarTerraformIdentityFileBase64, "") + + identityFile, err := base64.StdEncoding.DecodeString(identityFileBase64) + if err != nil { + return nil, trace.Wrap(err, "decoding base64 identity file") + } + + return client.LoadIdentityFileFromString(string(identityFile)), nil +} + +// CredentialsFromProfile builds credentials from a local tsh profile. +type CredentialsFromProfile struct { + // isDefault represent if the CredentialSource is used as the default one. + // In this case, it explains that it is always active. + isDefault bool +} + +// Name implements CredentialSource and returns the source name. +func (c CredentialsFromProfile) Name() string { + name := "from the local profile" + if c.isDefault { + name += " (default)" + } + return name +} + +// IsActive implements CredentialSource and returns if the source is active and why. +func (c CredentialsFromProfile) IsActive(config providerData) (bool, string) { + if c.isDefault { + return true, "this is the default credential source, and no other credential was active" + } + + profileName := stringFromConfigOrEnv(config.ProfileName, constants.EnvVarTerraformProfileName, "") + profileDir := stringFromConfigOrEnv(config.ProfileDir, constants.EnvVarTerraformProfilePath, "") + + // This method is active as soon as a cert or a key path are set. + active := profileDir != "" || profileName != "" + return activeReason( + active, + attributeTerraformProfileName, attributeTerraformProfilePath, + constants.EnvVarTerraformProfileName, constants.EnvVarTerraformProfilePath, + ) +} + +// Credentials implements CredentialSource and returns a client.Credentials for the provider. +func (CredentialsFromProfile) Credentials(ctx context.Context, config providerData) (client.Credentials, error) { + profileName := stringFromConfigOrEnv(config.ProfileName, constants.EnvVarTerraformProfileName, "") + profileDir := stringFromConfigOrEnv(config.ProfileDir, constants.EnvVarTerraformProfilePath, "") + + return client.LoadProfile(profileDir, profileName), nil +} + +// CredentialsFromNativeMachineID builds credentials by performing a MachineID join and +type CredentialsFromNativeMachineID struct{} + +// Name implements CredentialSource and returns the source name. +func (CredentialsFromNativeMachineID) Name() string { + return "by performing native MachineID joining" +} + +// IsActive implements CredentialSource and returns if the source is active and why. +func (CredentialsFromNativeMachineID) IsActive(config providerData) (bool, string) { + joinMethod := stringFromConfigOrEnv(config.JoinMethod, constants.EnvVarTerraformJoinMethod, "") + joinToken := stringFromConfigOrEnv(config.JoinToken, constants.EnvVarTerraformJoinToken, "") + + // This method is active as soon as a token or a join method are set. + active := joinMethod != "" || joinToken != "" + return activeReason( + active, + attributeTerraformJoinMethod, attributeTerraformJoinToken, + constants.EnvVarTerraformJoinMethod, constants.EnvVarTerraformJoinToken, + ) +} + +// Credentials implements CredentialSource and returns a client.Credentials for the provider. +func (CredentialsFromNativeMachineID) Credentials(ctx context.Context, config providerData) (client.Credentials, error) { + joinMethod := stringFromConfigOrEnv(config.JoinMethod, constants.EnvVarTerraformJoinMethod, "") + joinToken := stringFromConfigOrEnv(config.JoinToken, constants.EnvVarTerraformJoinToken, "") + addr := stringFromConfigOrEnv(config.Addr, constants.EnvVarTerraformAddress, "") + caPath := stringFromConfigOrEnv(config.RootCaPath, constants.EnvVarTerraformRootCertificates, "") + + if joinMethod == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformJoinMethod, constants.EnvVarTerraformJoinMethod) + } + if joinToken == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformJoinMethod, constants.EnvVarTerraformJoinMethod) + } + if addr == "" { + return nil, trace.BadParameter("missing parameter %q or environment variable %q", attributeTerraformAddress, constants.EnvVarTerraformAddress) + } + + if apitypes.JoinMethod(joinMethod) == apitypes.JoinMethodToken { + return nil, trace.BadParameter(`the secret token join method ('token') is not supported for native Machine ID joining. + +Secret tokens are single use and the Terraform provider does not save the certificates it obtained, so the token join method can only be used once. +If you want to run the Terraform provider in the CI (GitHub Actions, GitlabCI, Circle CI) or in a supported runtime (AWS, GCP, Azure, Kubernetes, machine with a TPM) +you should use the join method specific to your environment. +If you want to use MachineID with secret tokens, the best approach is to run a local tbot on the server where the terraform provider runs. + +See https://goteleport.com/docs/reference/join-methods for more details.`) + } + + if err := apitypes.ValidateJoinMethod(apitypes.JoinMethod(joinMethod)); err != nil { + return nil, trace.Wrap(err, "Invalid Join Method") + } + botConfig := &embeddedtbot.BotConfig{ + AuthServer: addr, + Onboarding: tbotconfig.OnboardingConfig{ + TokenValue: joinToken, + CAPath: caPath, + JoinMethod: apitypes.JoinMethod(joinMethod), + }, + CertificateTTL: time.Hour, + RenewalInterval: 20 * time.Minute, + } + bot, err := embeddedtbot.New(botConfig) + if err != nil { + return nil, trace.Wrap(err, "Failed to create bot configuration, this is a provider bug, please open a GitHub issue.") + } + + preflightCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + _, err = bot.Preflight(preflightCtx) + if err != nil { + return nil, trace.Wrap(err, "Failed to preflight bot configuration") + } + + creds, err := bot.StartAndWaitForCredentials(ctx, 20*time.Second /* deadline */) + return creds, trace.Wrap(err, "Waiting for bot to obtain credentials") +} + +// activeReason renders a user-friendly active reason message describing if the credentials source is active +// and which parameters are controlling its activity. +func activeReason(active bool, params ...string) (bool, string) { + sb := new(strings.Builder) + var firstConjunction, lastConjunction string + + switch active { + case true: + firstConjunction = "either " + lastConjunction = "or " + case false: + firstConjunction = "neither " + lastConjunction = "nor " + } + + sb.WriteString(firstConjunction) + + for i, item := range params { + switch i { + case len(params) - 1: + sb.WriteString(lastConjunction) + sb.WriteString(item) + sb.WriteRune(' ') + default: + sb.WriteString(item) + sb.WriteString(", ") + } + + } + sb.WriteString("are set") + return active, sb.String() +} diff --git a/integrations/terraform/provider/credentials_test.go b/integrations/terraform/provider/credentials_test.go new file mode 100644 index 0000000000000..528a878e348ff --- /dev/null +++ b/integrations/terraform/provider/credentials_test.go @@ -0,0 +1,118 @@ +/* +Copyright 2024 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 provider + +import ( + "context" + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/client" +) + +func TestActiveSources(t *testing.T) { + ctx := context.Background() + + activeSource1 := fakeActiveCredentialsSource{"active1"} + activeSource2 := fakeActiveCredentialsSource{"active2"} + inactiveSource1 := fakeInactiveCredentialsSource{"inactive1"} + inactiveSource2 := fakeInactiveCredentialsSource{"inactive2"} + + tests := []struct { + name string + sources CredentialSources + expectedSources CredentialSources + wantErr bool + }{ + { + name: "no source", + sources: CredentialSources{}, + expectedSources: nil, + wantErr: true, + }, + { + name: "no active source", + sources: CredentialSources{ + inactiveSource1, + inactiveSource2, + }, + expectedSources: nil, + wantErr: true, + }, + { + name: "single active source", + sources: CredentialSources{ + activeSource1, + }, + expectedSources: CredentialSources{activeSource1}, + wantErr: false, + }, + { + name: "multiple active and inactive sources", + sources: CredentialSources{ + inactiveSource1, + activeSource1, + inactiveSource2, + activeSource2, + }, + expectedSources: CredentialSources{activeSource1, activeSource2}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, diags := tt.sources.ActiveSources(ctx, providerData{}) + require.Equal(t, tt.wantErr, diags.HasError()) + require.Equal(t, tt.expectedSources, result) + }) + } +} + +type fakeActiveCredentialsSource struct { + name string +} + +func (f fakeActiveCredentialsSource) Name() string { + return f.name +} + +func (f fakeActiveCredentialsSource) IsActive(data providerData) (bool, string) { + return true, "" +} + +func (f fakeActiveCredentialsSource) Credentials(ctx context.Context, data providerData) (client.Credentials, error) { + return nil, trace.NotImplemented("not implemented") +} + +type fakeInactiveCredentialsSource struct { + name string +} + +func (f fakeInactiveCredentialsSource) Name() string { + return f.name +} + +func (f fakeInactiveCredentialsSource) IsActive(data providerData) (bool, string) { + return false, "" +} + +func (f fakeInactiveCredentialsSource) Credentials(ctx context.Context, data providerData) (client.Credentials, error) { + return nil, trace.NotImplemented("not implemented") +} diff --git a/integrations/terraform/provider/provider.go b/integrations/terraform/provider/provider.go index 1abb2a5d9a751..a3e54686f5e93 100644 --- a/integrations/terraform/provider/provider.go +++ b/integrations/terraform/provider/provider.go @@ -18,13 +18,9 @@ package provider import ( "context" - "crypto/tls" - "crypto/x509" - "encoding/base64" "fmt" "net" "os" - "path/filepath" "strconv" "strings" "time" @@ -38,6 +34,7 @@ import ( "google.golang.org/grpc/grpclog" "github.com/gravitational/teleport/api/client" + "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/lib/utils" ) @@ -46,6 +43,51 @@ const ( minServerVersion = "15.0.0-0" ) +const ( + // attributeTerraformAddress is the attribute configuring the Teleport address the Terraform provider connects to. + attributeTerraformAddress = "addr" + // attributeTerraformCertificates is the attribute configuring the path the Terraform provider loads its + // client certificates from. This only works for direct auth joining. + attributeTerraformCertificates = "cert_path" + // attributeTerraformCertificatesBase64 is the attribute configuring the client certificates used by the + // Terraform provider. This only works for direct auth joining. + attributeTerraformCertificatesBase64 = "cert_base64" + // attributeTerraformKey is the attribute configuring the path the Terraform provider loads its + // client key from. This only works for direct auth joining. + attributeTerraformKey = "key_path" + // attributeTerraformKeyBase64 is the attribute configuring the client key used by the + // Terraform provider. This only works for direct auth joining. + attributeTerraformKeyBase64 = "key_base64" + // attributeTerraformRootCertificates is the attribute configuring the path the Terraform provider loads its + // trusted CA certificates from. This only works for direct auth joining. + attributeTerraformRootCertificates = "root_ca_path" + // attributeTerraformRootCertificatesBase64 is the attribute configuring the CA certificates trusted by the + // Terraform provider. This only works for direct auth joining. + attributeTerraformRootCertificatesBase64 = "root_ca_base64" + // attributeTerraformProfileName is the attribute containing name of the profile used by the Terraform provider. + attributeTerraformProfileName = "profile_name" + // attributeTerraformProfilePath is the attribute containing the profile directory used by the Terraform provider. + attributeTerraformProfilePath = "profile_dir" + // attributeTerraformIdentityFilePath is the attribute containing the path to the identity file used by the provider. + attributeTerraformIdentityFilePath = "identity_file_path" + // attributeTerraformIdentityFile is the attribute containing the identity file used by the Terraform provider. + attributeTerraformIdentityFile = "identity_file" + // attributeTerraformIdentityFileBase64 is the attribute containing the base64-encoded identity file used by the Terraform provider. + attributeTerraformIdentityFileBase64 = "identity_file_base64" + // attributeTerraformRetryBaseDuration is the attribute configuring the base duration between two Terraform provider retries. + attributeTerraformRetryBaseDuration = "retry_base_duration" + // attributeTerraformRetryCapDuration is the attribute configuring the maximum duration between two Terraform provider retries. + attributeTerraformRetryCapDuration = "retry_cap_duration" + // attributeTerraformRetryMaxTries is the attribute configuring the maximum number of Terraform provider retries. + attributeTerraformRetryMaxTries = "retry_max_tries" + // attributeTerraformDialTimeoutDuration is the attribute configuring the Terraform provider dial timeout. + attributeTerraformDialTimeoutDuration = "dial_timeout_duration" + // attributeTerraformJoinMethod is the attribute configuring the Terraform provider native MachineID join method. + attributeTerraformJoinMethod = "join_method" + // attributeTerraformJoinToken is the attribute configuring the Terraform provider native MachineID join token. + attributeTerraformJoinToken = "join_token" +) + type RetryConfig struct { Base time.Duration Cap time.Duration @@ -57,6 +99,7 @@ type Provider struct { configured bool Client *client.Client RetryConfig RetryConfig + cancel context.CancelFunc } // providerData provider schema struct @@ -93,6 +136,10 @@ type providerData struct { RetryMaxTries types.String `tfsdk:"retry_max_tries"` // DialTimeout sets timeout when trying to connect to the server. DialTimeoutDuration types.String `tfsdk:"dial_timeout_duration"` + // JoinMethod is the MachineID join method. + JoinMethod types.String `tfsdk:"join_method"` + // JoinMethod is the MachineID join token. + JoinToken types.String `tfsdk:"join_token"` } // New returns an empty provider struct @@ -104,92 +151,104 @@ func New() tfsdk.Provider { func (p *Provider) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) { return tfsdk.Schema{ Attributes: map[string]tfsdk.Attribute{ - "addr": { + attributeTerraformAddress: { Type: types.StringType, Optional: true, - Description: "host:port where Teleport Auth Service is running. This can also be set with the environment variable `TF_TELEPORT_ADDR`.", + Description: fmt.Sprintf("host:port of the Teleport address. This can be the Teleport Proxy Service address (port 443 or 4080) or the Teleport Auth Service address (port 3025). This can also be set with the environment variable `%s`.", constants.EnvVarTerraformAddress), }, - "cert_path": { + attributeTerraformCertificates: { Type: types.StringType, Optional: true, - Description: "Path to Teleport auth certificate file. This can also be set with the environment variable `TF_TELEPORT_CERT`.", + Description: fmt.Sprintf("Path to Teleport auth certificate file. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformCertificates), }, - "cert_base64": { + attributeTerraformCertificatesBase64: { Type: types.StringType, Optional: true, - Description: "Base64 encoded TLS auth certificate. This can also be set with the environment variable `TF_TELEPORT_CERT_BASE64`.", + Description: fmt.Sprintf("Base64 encoded TLS auth certificate. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformCertificatesBase64), }, - "key_path": { + attributeTerraformKey: { Type: types.StringType, Optional: true, - Description: "Path to Teleport auth key file. This can also be set with the environment variable `TF_TELEPORT_KEY`.", + Description: fmt.Sprintf("Path to Teleport auth key file. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformKey), }, - "key_base64": { + attributeTerraformKeyBase64: { Type: types.StringType, Sensitive: true, Optional: true, - Description: "Base64 encoded TLS auth key. This can also be set with the environment variable `TF_TELEPORT_KEY_BASE64`.", + Description: fmt.Sprintf("Base64 encoded TLS auth key. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformKeyBase64), }, - "root_ca_path": { + attributeTerraformRootCertificates: { Type: types.StringType, Optional: true, - Description: "Path to Teleport Root CA. This can also be set with the environment variable `TF_TELEPORT_ROOT_CA`.", + Description: fmt.Sprintf("Path to Teleport Root CA. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformRootCertificates), }, - "root_ca_base64": { + attributeTerraformRootCertificatesBase64: { Type: types.StringType, Optional: true, - Description: "Base64 encoded Root CA. This can also be set with the environment variable `TF_TELEPORT_CA_BASE64`.", + Description: fmt.Sprintf("Base64 encoded Root CA. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformRootCertificatesBase64), }, - "profile_name": { + attributeTerraformProfileName: { Type: types.StringType, Optional: true, - Description: "Teleport profile name. This can also be set with the environment variable `TF_TELEPORT_PROFILE_NAME`.", + Description: fmt.Sprintf("Teleport profile name. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformProfileName), }, - "profile_dir": { + attributeTerraformProfilePath: { Type: types.StringType, Optional: true, - Description: "Teleport profile path. This can also be set with the environment variable `TF_TELEPORT_PROFILE_PATH`.", + Description: fmt.Sprintf("Teleport profile path. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformProfilePath), }, - "identity_file_path": { + attributeTerraformIdentityFilePath: { Type: types.StringType, Optional: true, - Description: "Teleport identity file path. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE_PATH`.", + Description: fmt.Sprintf("Teleport identity file path. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformIdentityFilePath), }, - "identity_file": { + attributeTerraformIdentityFile: { Type: types.StringType, Sensitive: true, Optional: true, - Description: "Teleport identity file content. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE`.", + Description: fmt.Sprintf("Teleport identity file content. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformIdentityFile), }, - "identity_file_base64": { + attributeTerraformIdentityFileBase64: { Type: types.StringType, Sensitive: true, Optional: true, - Description: "Teleport identity file content base64 encoded. This can also be set with the environment variable `TF_TELEPORT_IDENTITY_FILE_BASE64`.", + Description: fmt.Sprintf("Teleport identity file content base64 encoded. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformIdentityFileBase64), + }, + attributeTerraformRetryBaseDuration: { + Type: types.StringType, + Sensitive: false, + Optional: true, + Description: fmt.Sprintf("Retry algorithm when the API returns 'not found': base duration between retries (https://pkg.go.dev/time#ParseDuration). This can also be set with the environment variable `%s`.", constants.EnvVarTerraformRetryBaseDuration), }, - "retry_base_duration": { + attributeTerraformRetryCapDuration: { Type: types.StringType, Sensitive: false, Optional: true, - Description: "Retry algorithm when the API returns 'not found': base duration between retries (https://pkg.go.dev/time#ParseDuration). This can also be set with the environment variable `TF_TELEPORT_RETRY_BASE_DURATION`.", + Description: fmt.Sprintf("Retry algorithm when the API returns 'not found': max duration between retries (https://pkg.go.dev/time#ParseDuration). This can also be set with the environment variable `%s`.", constants.EnvVarTerraformRetryCapDuration), }, - "retry_cap_duration": { + attributeTerraformRetryMaxTries: { Type: types.StringType, Sensitive: false, Optional: true, - Description: "Retry algorithm when the API returns 'not found': max duration between retries (https://pkg.go.dev/time#ParseDuration). This can also be set with the environment variable `TF_TELEPORT_RETRY_CAP_DURATION`.", + Description: fmt.Sprintf("Retry algorithm when the API returns 'not found': max tries. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformRetryMaxTries), }, - "retry_max_tries": { + attributeTerraformDialTimeoutDuration: { Type: types.StringType, Sensitive: false, Optional: true, - Description: "Retry algorithm when the API returns 'not found': max tries. This can also be set with the environment variable `TF_TELEPORT_RETRY_MAX_TRIES`.", + Description: fmt.Sprintf("DialTimeout sets timeout when trying to connect to the server. This can also be set with the environment variable `%s`.", constants.EnvVarTerraformDialTimeoutDuration), }, - "dial_timeout_duration": { + attributeTerraformJoinMethod: { Type: types.StringType, Sensitive: false, Optional: true, - Description: "DialTimeout sets timeout when trying to connect to the server. This can also be set with the environment variable `TF_TELEPORT_DIAL_TIMEOUT_DURATION`.", + Description: fmt.Sprintf("Enables the native Terraform MachineID support. When set, Terraform uses MachineID to securely join the Teleport cluster and obtain credentials. See [the join method reference](./join-methods.mdx) for possible values, you must use [a delegated join method](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `%s`.", constants.EnvVarTerraformJoinMethod), + }, + attributeTerraformJoinToken: { + Type: types.StringType, + Sensitive: false, + Optional: true, + Description: fmt.Sprintf("Name of the token used for the native MachineID joining. This value is not sensitive for [delegated join methods](./join-methods.mdx#secret-vs-delegated). This can also be set with the environment variable `%s`.", constants.EnvVarTerraformJoinToken), }, }, }, nil @@ -209,10 +268,15 @@ func (p *Provider) IsConfigured(diags diag.Diagnostics) bool { // Configure configures the Teleport client func (p *Provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderRequest, resp *tfsdk.ConfigureProviderResponse) { - var creds []client.Credentials - p.configureLog() + // We wrap the provider's context into a cancellable one. + // This allows us to cancel the context and properly close the client and any background task potentially running + // (e.g. MachineID bot renewing creds). This is required during the tests as the provider is run multiple times. + // You can cancel the context by calling Provider.Close() + ctx, cancel := context.WithCancel(ctx) + p.cancel = cancel + var config providerData diags := req.Config.Get(ctx, &config) resp.Diagnostics.Append(diags...) @@ -220,22 +284,11 @@ func (p *Provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderReq return } - addr := p.stringFromConfigOrEnv(config.Addr, "TF_TELEPORT_ADDR", "") - certPath := p.stringFromConfigOrEnv(config.CertPath, "TF_TELEPORT_CERT", "") - certBase64 := p.stringFromConfigOrEnv(config.CertBase64, "TF_TELEPORT_CERT_BASE64", "") - keyPath := p.stringFromConfigOrEnv(config.KeyPath, "TF_TELEPORT_KEY", "") - keyBase64 := p.stringFromConfigOrEnv(config.KeyBase64, "TF_TELEPORT_KEY_BASE64", "") - caPath := p.stringFromConfigOrEnv(config.RootCaPath, "TF_TELEPORT_ROOT_CA", "") - caBase64 := p.stringFromConfigOrEnv(config.RootCaBase64, "TF_TELEPORT_CA_BASE64", "") - profileName := p.stringFromConfigOrEnv(config.ProfileName, "TF_TELEPORT_PROFILE_NAME", "") - profileDir := p.stringFromConfigOrEnv(config.ProfileDir, "TF_TELEPORT_PROFILE_PATH", "") - identityFilePath := p.stringFromConfigOrEnv(config.IdentityFilePath, "TF_TELEPORT_IDENTITY_FILE_PATH", "") - identityFile := p.stringFromConfigOrEnv(config.IdentityFile, "TF_TELEPORT_IDENTITY_FILE", "") - identityFileBase64 := p.stringFromConfigOrEnv(config.IdentityFileBase64, "TF_TELEPORT_IDENTITY_FILE_BASE64", "") - retryBaseDurationStr := p.stringFromConfigOrEnv(config.RetryBaseDuration, "TF_TELEPORT_RETRY_BASE_DURATION", "1s") - retryCapDurationStr := p.stringFromConfigOrEnv(config.RetryCapDuration, "TF_TELEPORT_RETRY_CAP_DURATION", "5s") - maxTriesStr := p.stringFromConfigOrEnv(config.RetryMaxTries, "TF_TELEPORT_RETRY_MAX_TRIES", "10") - dialTimeoutDurationStr := p.stringFromConfigOrEnv(config.DialTimeoutDuration, "TF_TELEPORT_DIAL_TIMEOUT_DURATION", "30s") + addr := stringFromConfigOrEnv(config.Addr, constants.EnvVarTerraformAddress, "") + retryBaseDurationStr := stringFromConfigOrEnv(config.RetryBaseDuration, constants.EnvVarTerraformRetryBaseDuration, "1s") + retryCapDurationStr := stringFromConfigOrEnv(config.RetryCapDuration, constants.EnvVarTerraformRetryCapDuration, "5s") + maxTriesStr := stringFromConfigOrEnv(config.RetryMaxTries, constants.EnvVarTerraformRetryMaxTries, "10") + dialTimeoutDurationStr := stringFromConfigOrEnv(config.DialTimeoutDuration, constants.EnvVarTerraformDialTimeoutDuration, "30s") if !p.validateAddr(addr, resp) { return @@ -243,83 +296,26 @@ func (p *Provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderReq log.WithFields(log.Fields{"addr": addr}).Debug("Using Teleport address") - if certPath != "" && keyPath != "" { - l := log.WithField("cert_path", certPath).WithField("key_path", keyPath).WithField("root_ca_path", caPath) - l.Debug("Using auth with certificate, private key and (optionally) CA read from files") - - cred, ok := p.getCredentialsFromKeyPair(certPath, keyPath, caPath, resp) - if !ok { - return - } - creds = append(creds, cred) - } - - if certBase64 != "" && keyBase64 != "" { - log.Debug("Using auth with certificate, private key and (optionally) CA read from base64 encoded vars") - cred, ok := p.getCredentialsFromBase64(certBase64, keyBase64, caBase64, resp) - if !ok { - return - } - creds = append(creds, cred) - } - - if identityFilePath != "" { - log.WithField("identity_file_path", identityFilePath).Debug("Using auth with identity file") - - if !p.fileExists(identityFilePath) { - resp.Diagnostics.AddError( - "Identity file not found", - fmt.Sprintf( - "File %v not found! Use `tctl auth sign --user=example@example.com --format=file --out=%v` to generate identity file", - identityFilePath, - identityFilePath, - ), - ) - return - } - - creds = append(creds, client.LoadIdentityFile(identityFilePath)) - } - - if identityFile != "" { - log.Debug("Using auth from identity file provided with environment variable TF_TELEPORT_IDENTITY_FILE") - creds = append(creds, client.LoadIdentityFileFromString(identityFile)) - } - - if identityFileBase64 != "" { - log.Debug("Using auth from base64 encoded identity file provided with environment variable TF_TELEPORT_IDENTITY_FILE_BASE64") - decoded, err := base64.StdEncoding.DecodeString(identityFileBase64) - if err != nil { - resp.Diagnostics.AddError( - "Failed to decode Identity file using base 64", - fmt.Sprintf("Error when trying to decode: %v", err), - ) - return - } - - creds = append(creds, client.LoadIdentityFileFromString(string(decoded))) - } - - if profileDir != "" || len(creds) == 0 { - log.WithFields(log.Fields{ - "dir": profileDir, - "name": profileName, - }).Debug("Using profile as the default auth method") - creds = append(creds, client.LoadProfile(profileDir, profileName)) - } - dialTimeoutDuration, err := time.ParseDuration(dialTimeoutDurationStr) if err != nil { resp.Diagnostics.AddError( "Failed to parse Dial Timeout Duration Cap Duration", - fmt.Sprintf("Please check if dial_timeout_duration (or TF_TELEPORT_DIAL_TIMEOUT_DURATION) is set correctly. Error: %s", err), + fmt.Sprintf( + "Please check if %s (or %s) is set correctly. Error: %s", + attributeTerraformDialTimeoutDuration, constants.EnvVarTerraformDialTimeoutDuration, err, + ), ) return } - client, err := client.New(ctx, client.Config{ + activeSources, diags := supportedCredentialSources.ActiveSources(ctx, config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + clientConfig := client.Config{ Addrs: []string{addr}, - Credentials: creds, DialTimeout: dialTimeoutDuration, DialOpts: []grpc.DialOption{ grpc.WithReturnConnectionError(), @@ -327,15 +323,15 @@ func (p *Provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderReq grpc.WaitForReady(true), ), }, - }) + } - if err != nil { - log.WithError(err).Debug("Error connecting to Teleport!") - resp.Diagnostics.AddError("Error connecting to Teleport!", err.Error()) + clt, diags := activeSources.BuildClient(ctx, clientConfig, config) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { return } - if !p.checkTeleportVersion(ctx, client, resp) { + if !p.checkTeleportVersion(ctx, clt, resp) { return } @@ -343,7 +339,10 @@ func (p *Provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderReq if err != nil { resp.Diagnostics.AddError( "Failed to parse Retry Base Duration", - fmt.Sprintf("Please check if retry_cap_duration (or TF_TELEPORT_RETRY_BASE_DURATION) is set correctly. Error: %s", err), + fmt.Sprintf( + "Please check if %s (or %s) is set correctly. Error: %s", + attributeTerraformRetryBaseDuration, constants.EnvVarTerraformRetryBaseDuration, err, + ), ) return } @@ -352,7 +351,10 @@ func (p *Provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderReq if err != nil { resp.Diagnostics.AddError( "Failed to parse Retry Cap Duration", - fmt.Sprintf("Please check if retry_cap_duration (or TF_TELEPORT_RETRY_CAP_DURATION) is set correctly. Error: %s", err), + fmt.Sprintf( + "Please check if %s (or %s) is set correctly. Error: %s", + attributeTerraformRetryCapDuration, constants.EnvVarTerraformRetryCapDuration, err, + ), ) return } @@ -361,7 +363,10 @@ func (p *Provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderReq if err != nil { resp.Diagnostics.AddError( "Failed to parse Retry Max Tries", - fmt.Sprintf("Please check if retry_max_tries (or TF_TELEPORT_RETRY_MAX_TRIES) is set correctly. Error: %s", err), + fmt.Sprintf( + "Please check if %s (or %s) is set correctly. Error: %s", + attributeTerraformRetryMaxTries, constants.EnvVarTerraformRetryMaxTries, err, + ), ) return } @@ -371,7 +376,7 @@ func (p *Provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderReq Cap: retryCapDuration, MaxTries: int(maxTries), } - p.Client = client + p.Client = clt p.configured = true } @@ -401,7 +406,7 @@ func (p *Provider) checkTeleportVersion(ctx context.Context, client *client.Clie } // stringFromConfigOrEnv returns value from config or from env var if config value is empty, default otherwise -func (p *Provider) stringFromConfigOrEnv(value types.String, env string, def string) string { +func stringFromConfigOrEnv(value types.String, env string, def string) string { if value.Unknown || value.Null { value := os.Getenv(env) if value != "" { @@ -423,109 +428,25 @@ func (p *Provider) validateAddr(addr string, resp *tfsdk.ConfigureProviderRespon if addr == "" { resp.Diagnostics.AddError( "Teleport address is empty", - "Please, specify either TF_TELEPORT_ADDR or addr in provider configuration", + fmt.Sprintf("Please, specify either %s in provider configuration, or the %s environment variable", + attributeTerraformAddress, constants.EnvVarTerraformAddress), ) return false } _, _, err := net.SplitHostPort(addr) if err != nil { - log.WithField("addr", addr).WithError(err).Debug("Teleport addr format error!") + log.WithField("addr", addr).WithError(err).Debug("Teleport address format error!") resp.Diagnostics.AddError( - "Invalid Teleport addr format", - "Teleport addr must be specified as host:port", + "Invalid Teleport address format", + fmt.Sprintf("Teleport address must be specified as host:port. Got %q", addr), ) return false } return true } -// getCredentialsFromBase64 returns client.Credentials built from base64 encoded keys -func (p *Provider) getCredentialsFromBase64(certBase64, keyBase64, caBase64 string, resp *tfsdk.ConfigureProviderResponse) (client.Credentials, bool) { - cert, err := base64.StdEncoding.DecodeString(certBase64) - if err != nil { - resp.Diagnostics.AddError( - "Failed to base64 decode cert", - fmt.Sprintf("Please check if cert_base64 (or TF_TELEPORT_CERT_BASE64) is set correctly. Error: %s", err), - ) - return nil, false - } - key, err := base64.StdEncoding.DecodeString(keyBase64) - if err != nil { - resp.Diagnostics.AddError( - "Failed to base64 decode key", - fmt.Sprintf("Please check if key_base64 (or TF_TELEPORT_KEY_BASE64) is set correctly. Error: %s", err), - ) - return nil, false - } - rootCa, err := base64.StdEncoding.DecodeString(caBase64) - if err != nil { - resp.Diagnostics.AddError( - "Failed to base64 decode root ca", - fmt.Sprintf("Please check if root_ca_base64 (or TF_TELEPORT_CA_BASE64) is set correctly. Error: %s", err), - ) - return nil, false - } - tlsConfig, err := createTLSConfig(cert, key, rootCa) - if err != nil { - resp.Diagnostics.AddError( - "Failed to create TLS config", - fmt.Sprintf("Error: %s", err), - ) - return nil, false - } - return client.LoadTLS(tlsConfig), true -} - -// getCredentialsFromKeyPair returns client.Credentials built from path to key files -func (p *Provider) getCredentialsFromKeyPair(certPath string, keyPath string, caPath string, resp *tfsdk.ConfigureProviderResponse) (client.Credentials, bool) { - if !p.fileExists(certPath) { - resp.Diagnostics.AddError( - "Certificate file not found", - fmt.Sprintf("File %v not found! Use 'tctl auth sign --user=example@example.com --format=tls --out=%v' to generate keys", - certPath, - filepath.Dir(certPath), - ), - ) - return nil, false - } - - if !p.fileExists(keyPath) { - resp.Diagnostics.AddError( - "Private key file not found", - fmt.Sprintf("File %v not found! Use 'tctl auth sign --user=example@example.com --format=tls --out=%v' to generate keys", - keyPath, - filepath.Dir(keyPath), - ), - ) - return nil, false - } - - if !p.fileExists(caPath) { - resp.Diagnostics.AddError( - "Root CA certificate file not found", - fmt.Sprintf("File %v not found! Use 'tctl auth sign --user=example@example.com --format=tls --out=%v' to generate keys", - caPath, - filepath.Dir(caPath), - ), - ) - return nil, false - } - - return client.LoadKeyPair(certPath, keyPath, caPath), true -} - -// fileExists returns true if file exists -func (p *Provider) fileExists(path string) bool { - _, err := os.Stat(path) - if os.IsNotExist(err) { - return false - } - if err != nil { - return false - } - return true -} +// TODO(hugoShaka): fix logging in a future release by converting to tflog. // configureLog configures logging func (p *Provider) configureLog() { @@ -546,21 +467,6 @@ func (p *Provider) configureLog() { } } -// createTLSConfig returns tls.Config build from keys -func createTLSConfig(cert, key, rootCa []byte) (*tls.Config, error) { - keyPair, err := tls.X509KeyPair(cert, key) - if err != nil { - return nil, err - } - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(rootCa) - - return &tls.Config{ - Certificates: []tls.Certificate{keyPair}, - RootCAs: caCertPool, - }, nil -} - // GetResources returns the map of provider resources func (p *Provider) GetResources(_ context.Context) (map[string]tfsdk.ResourceType, diag.Diagnostics) { return map[string]tfsdk.ResourceType{ @@ -608,3 +514,16 @@ func (p *Provider) GetDataSources(_ context.Context) (map[string]tfsdk.DataSourc "teleport_access_list": dataSourceTeleportAccessListType{}, }, nil } + +// Close closes the provider's client and cancels its context. +// This is needed in the tests to avoid accumulating clients and running out of file descriptors. +func (p *Provider) Close() error { + var err error + if p.Client != nil { + err = p.Client.Close() + } + if p.cancel != nil { + p.cancel() + } + return err +} diff --git a/integrations/terraform/testlib/machineid_join_test.go b/integrations/terraform/testlib/machineid_join_test.go new file mode 100644 index 0000000000000..52299c3cf457e --- /dev/null +++ b/integrations/terraform/testlib/machineid_join_test.go @@ -0,0 +1,164 @@ +/* +Copyright 2024 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 testlib + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-framework/providerserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/lib/testing/fakejoin" + "github.com/gravitational/teleport/integrations/lib/testing/integration" + "github.com/gravitational/teleport/lib/kubernetestoken" + "github.com/gravitational/teleport/lib/services" + + "github.com/gravitational/teleport/integrations/terraform/provider" +) + +func TestTerraformJoin(t *testing.T) { + require.NoError(t, os.Setenv("TF_ACC", "true")) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + // Test setup: start a Telpeort auth server + authHelper := &integration.MinimalAuthHelper{} + clt := authHelper.StartServer(t) + + var err error + // Test setup: create the terraform role + tfRole := services.NewPresetTerraformProviderRole() + tfRole, err = clt.CreateRole(ctx, tfRole) + require.NoError(t, err) + + // Test setup: create a fake Kubernetes signer that will allow us to use the kubernetes/jwks join method + clock := clockwork.NewRealClock() + signer, err := fakejoin.NewKubernetesSigner(clock) + require.NoError(t, err) + + jwks, err := signer.GetMarshaledJWKS() + require.NoError(t, err) + + // Test setup: create a token and a bot that can join the cluster with JWT signed by our fake Kubernetes signer + testBotName := "testBot" + testTokenName := "testToken" + fakeNamespace := "test-namespace" + fakeServiceAccount := "test-service-account" + token, err := types.NewProvisionTokenFromSpec( + testTokenName, + clock.Now().Add(time.Hour), + types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{types.RoleBot}, + JoinMethod: types.JoinMethodKubernetes, + BotName: testBotName, + Kubernetes: &types.ProvisionTokenSpecV2Kubernetes{ + Allow: []*types.ProvisionTokenSpecV2Kubernetes_Rule{ + { + ServiceAccount: fmt.Sprintf("%s:%s", fakeNamespace, fakeServiceAccount), + }, + }, + Type: types.KubernetesJoinTypeStaticJWKS, + StaticJWKS: &types.ProvisionTokenSpecV2Kubernetes_StaticJWKSConfig{ + JWKS: jwks, + }, + }, + }) + require.NoError(t, err) + err = clt.CreateToken(ctx, token) + require.NoError(t, err) + + bot := &machineidv1.Bot{ + Metadata: &headerv1.Metadata{ + Name: testBotName, + }, + Spec: &machineidv1.BotSpec{ + Roles: []string{tfRole.GetName()}, + }, + } + _, err = clt.BotServiceClient().CreateBot(ctx, &machineidv1.CreateBotRequest{Bot: bot}) + require.NoError(t, err) + + // Test setup: sign a Kube JWT for our bot to join the cluster + // We sign the token, write it to a temporary file, and point the embedded tbot to it + // with an environment variable. + pong, err := clt.Ping(ctx) + require.NoError(t, err) + clusterName := pong.ClusterName + jwt, err := signer.SignServiceAccountJWT("pod-name-doesnt-matter", fakeNamespace, fakeServiceAccount, clusterName) + require.NoError(t, err) + + tempDir := t.TempDir() + jwtPath := filepath.Join(tempDir, "token") + require.NoError(t, os.WriteFile(jwtPath, []byte(jwt), 0600)) + require.NoError(t, os.Setenv(kubernetestoken.EnvVarCustomKubernetesTokenPath, jwtPath)) + + // Test setup: craft a Terraform provider configuration + terraformConfig := fmt.Sprintf(` + provider "teleport" { + addr = %q + join_token = %q + join_method = %q + retry_base_duration = "900ms" + retry_cap_duration = "4s" + retry_max_tries = "12" + } + `, authHelper.ServerAddr(), testTokenName, types.JoinMethodKubernetes) + + terraformProvider := provider.New() + terraformProviders := make(map[string]func() (tfprotov6.ProviderServer, error)) + terraformProviders["teleport"] = func() (tfprotov6.ProviderServer, error) { + // Terraform configures provider on every test step, but does not clean up previous one, which produces + // to "too many open files" at some point. + // + // With this statement we try to forcefully close previously opened client, which stays cached in + // the provider variable. + p, ok := terraformProvider.(*provider.Provider) + require.True(t, ok) + require.NoError(t, p.Close()) + return providerserver.NewProtocol6(terraformProvider)(), nil + } + + // Test execution: apply a TF resource with the provider joining via MachineID + dummyResource, err := fixtures.ReadFile(filepath.Join("fixtures", "app_0_create.tf")) + require.NoError(t, err) + testConfig := terraformConfig + "\n" + string(dummyResource) + name := "teleport_app.test" + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: terraformProviders, + Steps: []resource.TestStep{ + { + Config: testConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, "kind", "app"), + resource.TestCheckResourceAttr(name, "spec.uri", "localhost:3000"), + ), + }, + }, + }) +} diff --git a/integrations/terraform/testlib/main_test.go b/integrations/terraform/testlib/main_test.go index c714f234d2b33..97d7d35aa805c 100644 --- a/integrations/terraform/testlib/main_test.go +++ b/integrations/terraform/testlib/main_test.go @@ -205,9 +205,7 @@ func (s *TerraformBaseSuite) closeClient() { s.T().Helper() p, ok := s.terraformProvider.(*provider.Provider) require.True(s.T(), ok) - if p != nil && p.Client != nil { - require.NoError(s.T(), p.Client.Close()) - } + require.NoError(s.T(), p.Close()) } // getFixture loads fixture and returns it as string or if failed diff --git a/lib/auth/init.go b/lib/auth/init.go index f2eb4762075c3..83c3c239780ea 100644 --- a/lib/auth/init.go +++ b/lib/auth/init.go @@ -971,6 +971,7 @@ func GetPresetRoles() []types.Role { services.NewPresetRequireTrustedDeviceRole(), services.NewSystemOktaAccessRole(), services.NewSystemOktaRequesterRole(), + services.NewPresetTerraformProviderRole(), } // Certain `New$FooRole()` functions will return a nil role if the diff --git a/lib/auth/init_test.go b/lib/auth/init_test.go index b7eed93825b07..45f2b0d88da87 100644 --- a/lib/auth/init_test.go +++ b/lib/auth/init_test.go @@ -497,6 +497,7 @@ func TestPresets(t *testing.T) { teleport.PresetEditorRoleName, teleport.PresetAccessRoleName, teleport.PresetAuditorRoleName, + teleport.PresetTerraformProviderRoleName, } t.Run("EmptyCluster", func(t *testing.T) { diff --git a/lib/kubernetestoken/token_source.go b/lib/kubernetestoken/token_source.go index 5d40296f9dd6b..55a506937cc89 100644 --- a/lib/kubernetestoken/token_source.go +++ b/lib/kubernetestoken/token_source.go @@ -24,7 +24,10 @@ import ( "github.com/gravitational/trace" ) -const kubernetesDefaultTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" +const ( + kubernetesDefaultTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + EnvVarCustomKubernetesTokenPath = "KUBERNETES_TOKEN_PATH" +) type getEnvFunc func(key string) string type readFileFunc func(name string) ([]byte, error) @@ -33,7 +36,7 @@ func GetIDToken(getEnv getEnvFunc, readFile readFileFunc) (string, error) { // We check if we should use a custom location instead of the default one. This env var is not standard. // This is useful when the operator wants to use a custom projected token, or another service account. path := kubernetesDefaultTokenPath - if customPath := getEnv("KUBERNETES_TOKEN_PATH"); customPath != "" { + if customPath := getEnv(EnvVarCustomKubernetesTokenPath); customPath != "" { path = customPath } diff --git a/lib/services/presets.go b/lib/services/presets.go index 46a0d7111aab8..0d173aa638291 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -29,6 +29,7 @@ import ( "github.com/gravitational/teleport/api/constants" apidefaults "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/types" + apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/lib/modules" ) @@ -557,6 +558,61 @@ func NewSystemOktaRequesterRole() types.Role { return role } +// NewPresetTerraformProviderRole returns a new pre-defined role for the Teleport Terraform provider. +// This role can edit any Terraform-supported resource. +func NewPresetTerraformProviderRole() types.Role { + role := &types.RoleV6{ + Kind: types.KindRole, + Version: types.V7, + Metadata: types.Metadata{ + Name: teleport.PresetTerraformProviderRoleName, + Namespace: apidefaults.Namespace, + Description: "Default Terraform provider role", + Labels: map[string]string{ + types.TeleportInternalResourceType: types.PresetResource, + }, + }, + Spec: types.RoleSpecV6{ + Allow: types.RoleConditions{ + // In Teleport, you can only see what you have access to. To be able to reconcile + // Apps, Databases, and Nodes, Terraform must be able to access them all. + // For Databases and Nodes, Terraform cannot actually access them because it has no + // Login/user set. + AppLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}}, + DatabaseLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}}, + NodeLabels: map[string]apiutils.Strings{types.Wildcard: []string{types.Wildcard}}, + // Every resource currently supported by the Terraform provider. + Rules: []types.Rule{ + { + Resources: []string{ + types.KindAccessList, + types.KindApp, + types.KindClusterAuthPreference, + types.KindClusterMaintenanceConfig, + types.KindClusterNetworkingConfig, + types.KindDatabase, + types.KindDevice, + types.KindGithub, + types.KindLoginRule, + types.KindNode, + types.KindOIDC, + types.KindOktaImportRule, + types.KindRole, + types.KindSAML, + types.KindSessionRecordingConfig, + types.KindToken, + types.KindTrustedCluster, + types.KindUser, + }, + Verbs: RW(), + }, + }, + }, + }, + } + return role +} + // bootstrapRoleMetadataLabels are metadata labels that will be applied to each role. // These are intended to add labels for older roles that didn't previously have them. func bootstrapRoleMetadataLabels() map[string]map[string]string { @@ -601,11 +657,17 @@ func defaultAllowRules() map[string][]types.Rule { // - DatabaseServiceLabels (db_service_labels) // - GroupLabels func defaultAllowLabels(enterprise bool) map[string]types.RoleConditions { + wildcardLabels := types.Labels{types.Wildcard: []string{types.Wildcard}} conditions := map[string]types.RoleConditions{ teleport.PresetAccessRoleName: { - DatabaseServiceLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + DatabaseServiceLabels: wildcardLabels, DatabaseRoles: []string{teleport.TraitInternalDBRolesVariable}, }, + teleport.PresetTerraformProviderRoleName: { + AppLabels: wildcardLabels, + DatabaseLabels: wildcardLabels, + NodeLabels: wildcardLabels, + }, } if enterprise { @@ -733,15 +795,21 @@ func AddRoleDefaults(role types.Role) (types.Role, error) { if ok { for _, kind := range []string{ types.KindApp, + types.KindDatabase, types.KindDatabaseService, + types.KindNode, types.KindUserGroup, } { var labels types.Labels switch kind { case types.KindApp: labels = defaultLabels.AppLabels + case types.KindDatabase: + labels = defaultLabels.DatabaseLabels case types.KindDatabaseService: labels = defaultLabels.DatabaseServiceLabels + case types.KindNode: + labels = defaultLabels.NodeLabels case types.KindUserGroup: labels = defaultLabels.GroupLabels } diff --git a/tool/tctl/common/cmds.go b/tool/tctl/common/cmds.go index 6074006e8ef61..1c727eba01e25 100644 --- a/tool/tctl/common/cmds.go +++ b/tool/tctl/common/cmds.go @@ -63,5 +63,6 @@ func Commands() []CLICommand { &fido2Command{}, &webauthnwinCommand{}, &touchIDCommand{}, + &TerraformCommand{}, } } diff --git a/tool/tctl/common/tctl.go b/tool/tctl/common/tctl.go index 24af9c1da1a24..eacd36f91c12b 100644 --- a/tool/tctl/common/tctl.go +++ b/tool/tctl/common/tctl.go @@ -196,6 +196,8 @@ func TryRun(commands []CLICommand, args []string) error { cfg.TeleportHome = filepath.Clean(cfg.TeleportHome) } + cfg.Debug = ccf.Debug + // configure all commands with Teleport configuration (they share 'cfg') clientConfig, err := ApplyConfig(&ccf, cfg) if err != nil { diff --git a/tool/tctl/common/terraform_command.go b/tool/tctl/common/terraform_command.go new file mode 100644 index 0000000000000..e326fe3884b56 --- /dev/null +++ b/tool/tctl/common/terraform_command.go @@ -0,0 +1,391 @@ +/* + * 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 common + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "log/slog" + "os" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/constants" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" + "github.com/gravitational/teleport/api/identityfile" + "github.com/gravitational/teleport/api/mfa" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/common" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/service/servicecfg" + "github.com/gravitational/teleport/lib/tbot" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/tbot/identity" + "github.com/gravitational/teleport/lib/tbot/ssh" + "github.com/gravitational/teleport/lib/tlsca" + "github.com/gravitational/teleport/lib/utils" +) + +const ( + terraformHelperDefaultResourcePrefix = "tctl-terraform-env-" + terraformHelperDefaultTTL = "1h" + + // importantText is the ANSI escape sequence used to make the terminal text bold. + importantText = "\033[1;31m" + // resetText is the ANSI escape sequence used to reset the terminal text style. + resetText = "\033[0m" +) + +var terraformEnvCommandLabels = map[string]string{ + common.TeleportNamespace + "/" + "created-by": "tctl-terraform-env", +} + +// TerraformCommand is a tctl command providing helpers for users to run the Terraform provider. +type TerraformCommand struct { + resourcePrefix string + existingRole string + botTTL time.Duration + + cfg *servicecfg.Config + + envCmd *kingpin.CmdClause + + // envOutput is where we write the `export env=value`, its value is os.Stdout when run via tctl, a custom buffer in tests. + envOutput io.Writer + // envOutput is where we write the progress updates, its value is os.Stderr run via tctl. + userOutput io.Writer + + log *slog.Logger +} + +// Initialize sets up the "tctl bots" command. +func (c *TerraformCommand) Initialize(app *kingpin.Application, cfg *servicecfg.Config) { + tfCmd := app.Command("terraform", "Helpers to run the Teleport Terraform Provider.") + + c.envCmd = tfCmd.Command("env", "Obtain certificates and load them into environments variables. This creates a temporary MachineID bot.") + c.envCmd.Flag( + "resource-prefix", + fmt.Sprintf("Resource prefix to use when creating the Terraform role and bots. Defaults to [%s]", terraformHelperDefaultResourcePrefix), + ).Default(terraformHelperDefaultResourcePrefix).StringVar(&c.resourcePrefix) + c.envCmd.Flag( + "bot-ttl", + fmt.Sprintf("Time-to-live of the Bot resource. The bot will be removed after this period. Defaults to [%s]", terraformHelperDefaultTTL), + ).Default(terraformHelperDefaultTTL).DurationVar(&c.botTTL) + c.envCmd.Flag( + "role", + fmt.Sprintf("Role used by Terraform. The role must already exist in Teleport. When not specified, uses the default role %q", teleport.PresetTerraformProviderRoleName), + ).StringVar(&c.existingRole) + + // Save a pointer to the config to be able to recover the Debug config later + c.cfg = cfg +} + +// TryRun attempts to run subcommands. +func (c *TerraformCommand) TryRun(ctx context.Context, cmd string, client *authclient.Client) (match bool, err error) { + switch cmd { + case c.envCmd.FullCommand(): + err = c.RunEnvCommand(ctx, client, os.Stdout, os.Stderr) + default: + return false, nil + } + + return true, trace.Wrap(err) +} + +// RunEnvCommand contains all the Terraform helper logic. It: +// - passes the MFA Challenge +// - creates the Terraform role +// - creates a temporary Terraform bot +// - uses the bot to obtain certificates for Terraform +// - exports certificates and Terraform configuration in environment variables +// envOutput and userOutput parameters are respectively stdout and stderr, +// except during tests where we want to catch the command output. +func (c *TerraformCommand) RunEnvCommand(ctx context.Context, client *authclient.Client, envOutput, userOutput io.Writer) error { + // If we're not actively debugging, suppress any kind of logging from other teleport components + if !c.cfg.Debug { + utils.InitLogger(utils.LoggingForCLI, slog.LevelError) + } + c.envOutput = envOutput + c.userOutput = userOutput + c.log = slog.Default() + + // Validate that the bot expires + if c.botTTL == 0 { + return trace.BadParameter("--bot-ttl must be greater than zero") + } + + addrs := c.cfg.AuthServerAddresses() + if len(addrs) == 0 { + return trace.BadParameter("no auth server addresses found") + } + addr := addrs[0] + + // Prompt for admin action MFA if required, allowing reuse for UpsertRole, UpsertToken and CreateBot. + c.showProgress("🔑 Detecting if MFA is required") + mfaResponse, err := mfa.PerformAdminActionMFACeremony(ctx, client.PerformMFACeremony, true /*allowReuse*/) + if err == nil { + ctx = mfa.ContextWithMFAResponse(ctx, mfaResponse) + } else if !errors.Is(err, &mfa.ErrMFANotRequired) && !errors.Is(err, &mfa.ErrMFANotSupported) { + return trace.Wrap(err) + } + + // Checking Terraform role + roleName, err := c.checkIfRoleExists(ctx, client) + if err != nil { + switch { + case trace.IsNotFound(err) && c.existingRole == "": + return trace.Wrap(err, `The Terraform role %q does not exist in your Teleport cluster. +This default role is included in Teleport clusters whose version is higher than v16.2 or v17. +If you want to use "tctl terraform env" against an older Teleport cluster, you must create the Terraform role +yourself and set the flag --role .`, roleName) + case trace.IsNotFound(err) && c.existingRole != "": + return trace.Wrap(err, `The Terraform role %q specified with --role does not exist in your Teleport cluster. +Please check that the role exists in the cluster.`, roleName) + case trace.IsAccessDenied(err): + return trace.Wrap(err, `Failed to validate if the role %q exists. +To use the "tctl terraform env" command you must have rights to list and read Teleport roles. +If you got a role granted recently, you might have to run "tsh logout" and login again.`, roleName) + default: + return trace.Wrap(err, "Unexpected error while trying to validate if the role %q exists.", roleName) + } + } + + // Create temporary bot and token + tokenName, err := c.createTransientBotAndToken(ctx, client, roleName) + if trace.IsAccessDenied(err) { + return trace.Wrap(err, `Failed to create the temporary Terraform bot or its token. +To use the "tctl terraform env" command you must have rights to create Teleport bot and token resources. +If you got a role granted recently, you might have to run "tsh logout" and login again.`) + } + if err != nil { + return trace.Wrap(err, "bootstrapping bot") + } + + // Now run tbot + c.showProgress("🤖 Using the temporary bot to obtain certificates") + id, sshHostCACerts, err := c.useBotToObtainIdentity(ctx, addr, tokenName, client) + if err != nil { + return trace.Wrap(err, "The temporary bot failed to connect to Teleport.") + } + + envVars, err := identityToTerraformEnvVars(addr.String(), id, sshHostCACerts) + if err != nil { + return trace.Wrap(err, "exporting identity into environment variables") + } + + // Export environment variables + c.showProgress(fmt.Sprintf("🚀 Certificates obtained, you can now use Terraform in this terminal for %s", c.botTTL.String())) + for env, value := range envVars { + fmt.Fprintf(c.envOutput, "export %s=%q\n", env, value) + } + fmt.Fprintln(c.envOutput, "#") + fmt.Fprintf(c.envOutput, "# %sYou must invoke this command in an eval: eval $(tctl terraform env)%s\n", importantText, resetText) + return nil +} + +// createTransientBotAndToken creates a Bot resource and a secret Token. +// The token is single use (secret tokens are consumed on MachineID join) +// and the bot expires after the given TTL. +func (c *TerraformCommand) createTransientBotAndToken(ctx context.Context, client *authclient.Client, roleName string) (string, error) { + // Create token and bot name + suffix, err := utils.CryptoRandomHex(4) + if err != nil { + return "", trace.Wrap(err) + } + + botName := c.resourcePrefix + suffix + c.showProgress(fmt.Sprintf("⚙️ Creating temporary bot %q and its token", botName)) + + // Generate a token + tokenName, err := utils.CryptoRandomHex(defaults.TokenLenBytes) + if err != nil { + return "", trace.Wrap(err, "generating random token") + } + tokenSpec := types.ProvisionTokenSpecV2{ + Roles: types.SystemRoles{types.RoleBot}, + JoinMethod: types.JoinMethodToken, + BotName: botName, + } + // Token should be consumed on bot join in a few seconds. If the bot fails to join for any reason, + // the token should not outlive the bot. + token, err := types.NewProvisionTokenFromSpec(tokenName, time.Now().Add(c.botTTL), tokenSpec) + if err != nil { + return "", trace.Wrap(err) + } + token.SetLabels(terraformEnvCommandLabels) + if err := client.UpsertToken(ctx, token); err != nil { + return "", trace.Wrap(err, "upserting token") + } + + // Create bot + bot := &machineidv1pb.Bot{ + Metadata: &headerv1.Metadata{ + Name: botName, + Expires: timestamppb.New(time.Now().Add(c.botTTL)), + Labels: terraformEnvCommandLabels, + }, + Spec: &machineidv1pb.BotSpec{ + Roles: []string{roleName}, + }, + } + + _, err = client.BotServiceClient().CreateBot(ctx, &machineidv1pb.CreateBotRequest{ + Bot: bot, + }) + if err != nil { + return "", trace.Wrap(err, "creating bot") + } + return tokenName, nil +} + +// roleClient describes the minimal set of operations that the helper uses to +// create the Terraform provider role. +type roleClient interface { + UpsertRole(context.Context, types.Role) (types.Role, error) + GetRole(context.Context, string) (types.Role, error) +} + +// createRoleIfNeeded checks if the terraform role exists. +// Returns the Terraform role name even in case of error, so this can be used to craft nice error messages. +func (c *TerraformCommand) checkIfRoleExists(ctx context.Context, client roleClient) (string, error) { + roleName := c.existingRole + + if roleName == "" { + roleName = teleport.PresetTerraformProviderRoleName + } + _, err := client.GetRole(ctx, roleName) + + return roleName, trace.Wrap(err) +} + +// useBotToObtainIdentity takes secret bot token and runs a one-shot in-process tbot to trade the token +// against valid certificates. Those certs are then serialized into an identity file. +// The output is a set of environment variables, one of them including the base64-encoded identity file. +// Later, the Terraform provider will read those environment variables to build its Teleport client. +// Note: the function also returns the SSH Host CA cert encoded in the known host format. +// The identity.Identity uses a different format (authorized keys). +func (c *TerraformCommand) useBotToObtainIdentity(ctx context.Context, addr utils.NetAddr, token string, clt *authclient.Client) (*identity.Identity, [][]byte, error) { + credential := &config.UnstableClientCredentialOutput{} + cfg := &config.BotConfig{ + Version: "", + Onboarding: config.OnboardingConfig{ + TokenValue: token, + JoinMethod: types.JoinMethodToken, + }, + Storage: &config.StorageConfig{Destination: &config.DestinationMemory{}}, + Services: config.ServiceConfigs{credential}, + CertificateTTL: c.botTTL, + Oneshot: true, + // If --insecure is passed, the bot will trust the certificate on first use. + // This does not truly disable TLS validation, only trusts the certificate on first connection. + Insecure: clt.Config().InsecureSkipVerify, + } + + // When invoked only with auth address, tbot will try both joining as an auth and as a proxy. + // This allows us to not care about how the user connects to Teleport (auth vs proxy joining). + cfg.AuthServer = addr.String() + + // Insecure joining is not compatible with CA pinning + if !cfg.Insecure { + // We use the client to get the TLS CA and compute its fingerprint. + // In case of auth joining, this ensures that tbot connects to the same Teleport auth as we do + // (no man in the middle possible between when we build the auth client and when we run tbot). + localCAResponse, err := clt.GetClusterCACert(ctx) + if err != nil { + return nil, nil, trace.Wrap(err, "getting cluster CA certificate") + } + caPins, err := tlsca.CalculatePins(localCAResponse.TLSCA) + if err != nil { + return nil, nil, trace.Wrap(err, "calculating CA pins") + } + cfg.Onboarding.CAPins = caPins + } + + err := cfg.CheckAndSetDefaults() + if err != nil { + return nil, nil, trace.Wrap(err, "checking the bot's configuration") + } + + // Run the bot + bot := tbot.New(cfg, c.log) + err = bot.Run(ctx) + if err != nil { + return nil, nil, trace.Wrap(err, "running the bot") + } + + // Retrieve the credentials obtained by tbot. + facade, err := credential.Facade() + if err != nil { + return nil, nil, trace.Wrap(err, "accessing credentials") + } + + id := facade.Get() + + clusterName, err := clt.GetClusterName() + if err != nil { + return nil, nil, trace.Wrap(err, "retrieving cluster name") + } + knownHosts, err := ssh.GenerateKnownHosts(ctx, clt, []string{clusterName.GetClusterName()}, addr.Host()) + if err != nil { + return nil, nil, trace.Wrap(err, "retrieving SSH Host CA") + } + sshHostCACerts := [][]byte{[]byte(knownHosts)} + + return id, sshHostCACerts, nil +} + +// showProgress sends status update messages ot the user. +func (c *TerraformCommand) showProgress(update string) { + _, _ = fmt.Fprintln(c.userOutput, update) +} + +// identityToTerraformEnvVars takes an identity and builds environment variables +// configuring the Terraform provider to use this identity. +// The sshHostCACerts must be in the "known hosts" format. +func identityToTerraformEnvVars(addr string, id *identity.Identity, sshHostCACerts [][]byte) (map[string]string, error) { + idFile := &identityfile.IdentityFile{ + PrivateKey: id.PrivateKeyBytes, + Certs: identityfile.Certs{ + SSH: id.CertBytes, + TLS: id.TLSCertBytes, + }, + CACerts: identityfile.CACerts{ + SSH: sshHostCACerts, + TLS: id.TLSCACertsBytes, + }, + } + idBytes, err := identityfile.Encode(idFile) + if err != nil { + return nil, trace.Wrap(err) + } + idBase64 := base64.StdEncoding.EncodeToString(idBytes) + return map[string]string{ + constants.EnvVarTerraformAddress: addr, + constants.EnvVarTerraformIdentityFileBase64: idBase64, + }, nil +} diff --git a/tool/tctl/common/terraform_command_test.go b/tool/tctl/common/terraform_command_test.go new file mode 100644 index 0000000000000..e1db99aa73347 --- /dev/null +++ b/tool/tctl/common/terraform_command_test.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 common + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/lib/testing/integration" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/utils" +) + +// Note: due to its complex interactions with Teleport, the `tctl terraform env` +// command is mainly not tested via unit tests but by integration tests validating the full flow. +// You can find its integration tests in `integration/tctl_terraform_env_test.go` + +func TestTerraformCommand_checkIfRoleExists(t *testing.T) { + // Test setup + authHelper := integration.MinimalAuthHelper{} + adminClient := authHelper.StartServer(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + newRoleFixture := func(name string) types.Role { + role := services.NewPresetTerraformProviderRole() + role.SetName(name) + return role + } + + tests := []struct { + name string + // Test setup + existingRoleFlag string + fixture types.Role + // Test validation + expectedRoleName string + expectedErr require.ErrorAssertionFunc + }{ + { + name: "Succeeds if preset role is found", + existingRoleFlag: "", + fixture: newRoleFixture(teleport.PresetTerraformProviderRoleName), + expectedRoleName: teleport.PresetTerraformProviderRoleName, + expectedErr: require.NoError, + }, + { + name: "Fails if preset role is not found", + existingRoleFlag: "", + expectedRoleName: teleport.PresetTerraformProviderRoleName, + expectedErr: require.Error, + }, + { + name: "Succeeds if custom existing role is specified and exists", + existingRoleFlag: "existing-role", + fixture: newRoleFixture("existing-role"), + expectedRoleName: "existing-role", + expectedErr: require.NoError, + }, + { + name: "Fails if custom existing role is specified and does not exist", + existingRoleFlag: "existing-role", + expectedRoleName: "existing-role", + expectedErr: require.Error, + }, + } + for _, tt := range tests { + // Warning: Those tests cannot be run in parallel + t.Run(tt.name, func(t *testing.T) { + // Test case setup + if tt.fixture != nil { + _, err := adminClient.CreateRole(ctx, tt.fixture) + require.NoError(t, err) + } + + // Test execution + c := &TerraformCommand{ + existingRole: tt.existingRoleFlag, + userOutput: os.Stderr, + log: utils.NewSlogLoggerForTests(), + } + roleName, err := c.checkIfRoleExists(ctx, adminClient) + tt.expectedErr(t, err) + require.Equal(t, tt.expectedRoleName, roleName) + + // Test cleanup + if tt.fixture != nil { + require.NoError(t, adminClient.DeleteRole(ctx, roleName)) + } + }) + } +}