From c31d9cd57327b03b2d939f4e340abf18c1676516 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 26 Mar 2022 01:56:41 +0000 Subject: [PATCH 1/3] feat: Add AWS instance identity authentication This allows zero-trust authentication for all AWS instances. Prior to this, AWS instances could be used by passing `CODER_TOKEN` as an environment variable to the startup script. AWS explicitly states that secrets should not be passed in startup scripts because it's user-readable. --- cli/workspaceagent.go | 41 ++++- cli/workspaceagent_test.go | 56 ++++++- coderd/awsidentity/awsidentity.go | 223 +++++++++++++++++++++++++ coderd/awsidentity/awsidentity_test.go | 53 ++++++ coderd/coderd.go | 3 + coderd/coderdtest/coderdtest.go | 74 +++++++- coderd/coderdtest/coderdtest_test.go | 1 + coderd/workspaceresourceauth.go | 29 +++- coderd/workspaceresourceauth_test.go | 60 +++++-- codersdk/workspaceresourceauth.go | 74 ++++++++ 10 files changed, 580 insertions(+), 34 deletions(-) create mode 100644 coderd/awsidentity/awsidentity.go create mode 100644 coderd/awsidentity/awsidentity_test.go diff --git a/cli/workspaceagent.go b/cli/workspaceagent.go index 369fe8010445b..05bd0154a81fa 100644 --- a/cli/workspaceagent.go +++ b/cli/workspaceagent.go @@ -2,6 +2,7 @@ package cli import ( "context" + "net/http" "net/url" "os" "time" @@ -38,6 +39,11 @@ func workspaceAgent() *cobra.Command { } logger := slog.Make(sloghuman.Sink(cmd.OutOrStdout())).Leveled(slog.LevelDebug) client := codersdk.New(coderURL) + + // exchangeToken returns a session token. + // This is abstracted to allow for the same looping condition + // regardless of instance identity auth type. + var exchangeToken func(context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) switch auth { case "token": sessionToken, exists := os.LookupEnv("CODER_TOKEN") @@ -53,29 +59,48 @@ func workspaceAgent() *cobra.Command { if gcpClientRaw != nil { gcpClient, _ = gcpClientRaw.(*metadata.Client) } + exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) { + return client.AuthWorkspaceGoogleInstanceIdentity(ctx, "", gcpClient) + } + case "aws-instance-identity": + // This is *only* done for testing to mock client authentication. + // This will never be set in a production scenario. + var awsClient *http.Client + awsClientRaw := cmd.Context().Value("aws-client") + if awsClientRaw != nil { + awsClient, _ = awsClientRaw.(*http.Client) + } + exchangeToken = func(ctx context.Context) (codersdk.WorkspaceAgentAuthenticateResponse, error) { + return client.AuthWorkspaceAWSInstanceIdentity(ctx, awsClient) + } + case "azure-instance-identity": + return xerrors.Errorf("not implemented") + } - ctx, cancelFunc := context.WithTimeout(cmd.Context(), 30*time.Second) + if exchangeToken != nil { + // Agent's can start before resources are returned from the provisioner + // daemon. If there are many resources being provisioned, this time + // could be significant. This is arbitrarily set at an hour to prevent + // tons of idle agents from pinging coderd. + ctx, cancelFunc := context.WithTimeout(cmd.Context(), time.Hour) defer cancelFunc() for retry.New(100*time.Millisecond, 5*time.Second).Wait(ctx) { var response codersdk.WorkspaceAgentAuthenticateResponse - response, err = client.AuthWorkspaceGoogleInstanceIdentity(ctx, "", gcpClient) + response, err = exchangeToken(ctx) if err != nil { - logger.Warn(ctx, "authenticate workspace with Google Instance Identity", slog.Error(err)) + logger.Warn(ctx, "authenticate workspace", slog.F("method", auth), slog.Error(err)) continue } client.SessionToken = response.SessionToken - logger.Info(ctx, "authenticated with Google Instance Identity") + logger.Info(ctx, "authenticated", slog.F("method", auth)) break } if err != nil { return xerrors.Errorf("agent failed to authenticate in time: %w", err) } - case "aws-instance-identity": - return xerrors.Errorf("not implemented") - case "azure-instance-identity": - return xerrors.Errorf("not implemented") } + closer := agent.New(client.ListenWorkspaceAgent, &peer.ConnOptions{ Logger: logger, }) diff --git a/cli/workspaceagent_test.go b/cli/workspaceagent_test.go index 3a9096e1b4f98..72dda966ca272 100644 --- a/cli/workspaceagent_test.go +++ b/cli/workspaceagent_test.go @@ -14,12 +14,66 @@ import ( func TestWorkspaceAgent(t *testing.T) { t.Parallel() + t.Run("AWS", func(t *testing.T) { + t.Parallel() + instanceID := "instanceidentifier" + certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID) + client := coderdtest.New(t, &coderdtest.Options{ + AWSInstanceIdentity: certificates, + }) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "somename", + Type: "someinstance", + Agent: &proto.Agent{ + Auth: &proto.Agent_InstanceId{ + InstanceId: instanceID, + }, + }, + }}, + }, + }, + }}, + }) + project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitProjectVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + cmd, _ := clitest.New(t, "workspaces", "agent", "--auth", "aws-instance-identity", "--url", client.URL.String()) + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + go func() { + // A linting error occurs for weakly typing the context value here, + // but it seems reasonable for a one-off test. + // nolint + ctx = context.WithValue(ctx, "aws-client", metadataClient) + err := cmd.ExecuteContext(ctx) + require.NoError(t, err) + }() + coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].ID, nil, nil) + require.NoError(t, err) + defer dialer.Close() + _, err = dialer.Ping() + require.NoError(t, err) + cancelFunc() + }) + t.Run("GoogleCloud", func(t *testing.T) { t.Parallel() instanceID := "instanceidentifier" validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) client := coderdtest.New(t, &coderdtest.Options{ - GoogleTokenValidator: validator, + GoogleInstanceIdentity: validator, }) user := coderdtest.CreateFirstUser(t, client) coderdtest.NewProvisionerDaemon(t, client) diff --git a/coderd/awsidentity/awsidentity.go b/coderd/awsidentity/awsidentity.go new file mode 100644 index 0000000000000..0883da73c83c5 --- /dev/null +++ b/coderd/awsidentity/awsidentity.go @@ -0,0 +1,223 @@ +package awsidentity + +import ( + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + + "golang.org/x/xerrors" +) + +// Region represents the AWS locations a public-key covers. +type Region string + +const ( + Other Region = "other" + HongKong Region = "hongkong" + Bahrain Region = "bahrain" + CapeTown Region = "capetown" + Milan Region = "milan" + China Region = "china" + GovCloud Region = "govcloud" +) + +var ( + All = []Region{Other, HongKong, Bahrain, CapeTown, Milan, China, GovCloud} +) + +// Certificates hold public keys for various AWS regions. See: +type Certificates map[Region]string + +// Identity represents a validated document and signature. +type Identity struct { + InstanceID string + Region Region +} + +// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html +type awsInstanceIdentityDocument struct { + InstanceID string `json:"instanceId"` +} + +// Validate ensures the document was signed by an AWS public key. +// Regions that aren't provided in certificates will use defaults. +func Validate(signature, document string, certificates Certificates) (Identity, error) { + if certificates == nil { + certificates = Certificates{} + } + for _, region := range All { + if _, ok := certificates[region]; ok { + continue + } + defaultCertificate, exists := defaultCertificates[region] + if !exists { + panic("dev error: no certificate exists for region " + region) + } + certificates[region] = defaultCertificate + } + + var instanceIdentity awsInstanceIdentityDocument + err := json.Unmarshal([]byte(document), &instanceIdentity) + if err != nil { + return Identity{}, xerrors.Errorf("parse document: %w", err) + } + rawSignature, err := base64.StdEncoding.DecodeString(signature) + if err != nil { + return Identity{}, xerrors.Errorf("decode signature: %w", err) + } + documentHash := sha256.New() + _, err = documentHash.Write([]byte(document)) + if err != nil { + return Identity{}, xerrors.Errorf("write document hash: %w", err) + } + hashedDocument := documentHash.Sum(nil) + + for region, certificate := range certificates { + regionBlock, rest := pem.Decode([]byte(certificate)) + if len(rest) != 0 { + return Identity{}, xerrors.Errorf("invalid certificate for %q. %d bytes remain", region, len(rest)) + } + regionCert, err := x509.ParseCertificate(regionBlock.Bytes) + if err != nil { + return Identity{}, xerrors.Errorf("parse certificate: %w", err) + } + regionPublicKey, valid := regionCert.PublicKey.(*rsa.PublicKey) + if !valid { + return Identity{}, xerrors.Errorf("certificate for %q was not an rsa key", region) + } + err = rsa.VerifyPKCS1v15(regionPublicKey, crypto.SHA256, hashedDocument, rawSignature) + if err != nil { + continue + } + return Identity{ + InstanceID: instanceIdentity.InstanceID, + Region: region, + }, nil + } + return Identity{}, rsa.ErrVerification +} + +// Default AWS certificates for regions. +// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/verify-signature.html +var defaultCertificates = Certificates{ + Other: `-----BEGIN CERTIFICATE----- +MIIDIjCCAougAwIBAgIJAKnL4UEDMN/FMA0GCSqGSIb3DQEBBQUAMGoxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdTZWF0dGxlMRgw +FgYDVQQKEw9BbWF6b24uY29tIEluYy4xGjAYBgNVBAMTEWVjMi5hbWF6b25hd3Mu +Y29tMB4XDTE0MDYwNTE0MjgwMloXDTI0MDYwNTE0MjgwMlowajELMAkGA1UEBhMC +VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1NlYXR0bGUxGDAWBgNV +BAoTD0FtYXpvbi5jb20gSW5jLjEaMBgGA1UEAxMRZWMyLmFtYXpvbmF3cy5jb20w +gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAIe9GN//SRK2knbjySG0ho3yqQM3 +e2TDhWO8D2e8+XZqck754gFSo99AbT2RmXClambI7xsYHZFapbELC4H91ycihvrD +jbST1ZjkLQgga0NE1q43eS68ZeTDccScXQSNivSlzJZS8HJZjgqzBlXjZftjtdJL +XeE4hwvo0sD4f3j9AgMBAAGjgc8wgcwwHQYDVR0OBBYEFCXWzAgVyrbwnFncFFIs +77VBdlE4MIGcBgNVHSMEgZQwgZGAFCXWzAgVyrbwnFncFFIs77VBdlE4oW6kbDBq +MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHU2Vh +dHRsZTEYMBYGA1UEChMPQW1hem9uLmNvbSBJbmMuMRowGAYDVQQDExFlYzIuYW1h +em9uYXdzLmNvbYIJAKnL4UEDMN/FMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEF +BQADgYEAFYcz1OgEhQBXIwIdsgCOS8vEtiJYF+j9uO6jz7VOmJqO+pRlAbRlvY8T +C1haGgSI/A1uZUKs/Zfnph0oEI0/hu1IIJ/SKBDtN5lvmZ/IzbOPIJWirlsllQIQ +7zvWbGd9c9+Rm3p04oTvhup99la7kZqevJK0QRdD/6NpCKsqP/0= +-----END CERTIFICATE-----`, + HongKong: `-----BEGIN CERTIFICATE----- +MIICSzCCAbQCCQDtQvkVxRvK9TANBgkqhkiG9w0BAQsFADBqMQswCQYDVQQGEwJV +UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHU2VhdHRsZTEYMBYGA1UE +ChMPQW1hem9uLmNvbSBJbmMuMRowGAYDVQQDExFlYzIuYW1hem9uYXdzLmNvbTAe +Fw0xOTAyMDMwMzAwMDZaFw0yOTAyMDIwMzAwMDZaMGoxCzAJBgNVBAYTAlVTMRMw +EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdTZWF0dGxlMRgwFgYDVQQKEw9B +bWF6b24uY29tIEluYy4xGjAYBgNVBAMTEWVjMi5hbWF6b25hd3MuY29tMIGfMA0G +CSqGSIb3DQEBAQUAA4GNADCBiQKBgQC1kkHXYTfc7gY5Q55JJhjTieHAgacaQkiR +Pity9QPDE3b+NXDh4UdP1xdIw73JcIIG3sG9RhWiXVCHh6KkuCTqJfPUknIKk8vs +M3RXflUpBe8Pf+P92pxqPMCz1Fr2NehS3JhhpkCZVGxxwLC5gaG0Lr4rFORubjYY +Rh84dK98VwIDAQABMA0GCSqGSIb3DQEBCwUAA4GBAA6xV9f0HMqXjPHuGILDyaNN +dKcvplNFwDTydVg32MNubAGnecoEBtUPtxBsLoVYXCOb+b5/ZMDubPF9tU/vSXuo +TpYM5Bq57gJzDRaBOntQbX9bgHiUxw6XZWaTS/6xjRJDT5p3S1E0mPI3lP/eJv4o +Ezk5zb3eIf10/sqt4756 +-----END CERTIFICATE-----`, + Bahrain: `-----BEGIN CERTIFICATE----- +MIIDPDCCAqWgAwIBAgIJAMl6uIV/zqJFMA0GCSqGSIb3DQEBCwUAMHIxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIDApXYXNoaW5ndG9uMRAwDgYDVQQHDAdTZWF0dGxlMSAw +HgYDVQQKDBdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzEaMBgGA1UEAwwRZWMyLmFt +YXpvbmF3cy5jb20wIBcNMTkwNDI2MTQzMjQ3WhgPMjE5ODA5MjkxNDMyNDdaMHIx +CzAJBgNVBAYTAlVTMRMwEQYDVQQIDApXYXNoaW5ndG9uMRAwDgYDVQQHDAdTZWF0 +dGxlMSAwHgYDVQQKDBdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzEaMBgGA1UEAwwR +ZWMyLmFtYXpvbmF3cy5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBALVN +CDTZEnIeoX1SEYqq6k1BV0ZlpY5y3KnoOreCAE589TwS4MX5+8Fzd6AmACmugeBP +Qk7Hm6b2+g/d4tWycyxLaQlcq81DB1GmXehRkZRgGeRge1ePWd1TUA0I8P/QBT7S +gUePm/kANSFU+P7s7u1NNl+vynyi0wUUrw7/wIZTAgMBAAGjgdcwgdQwHQYDVR0O +BBYEFILtMd+T4YgH1cgc+hVsVOV+480FMIGkBgNVHSMEgZwwgZmAFILtMd+T4YgH +1cgc+hVsVOV+480FoXakdDByMQswCQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGlu +Z3RvbjEQMA4GA1UEBwwHU2VhdHRsZTEgMB4GA1UECgwXQW1hem9uIFdlYiBTZXJ2 +aWNlcyBMTEMxGjAYBgNVBAMMEWVjMi5hbWF6b25hd3MuY29tggkAyXq4hX/OokUw +DAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOBgQBhkNTBIFgWFd+ZhC/LhRUY +4OjEiykmbEp6hlzQ79T0Tfbn5A4NYDI2icBP0+hmf6qSnIhwJF6typyd1yPK5Fqt +NTpxxcXmUKquX+pHmIkK1LKDO8rNE84jqxrxRsfDi6by82fjVYf2pgjJW8R1FAw+ +mL5WQRFexbfB5aXhcMo0AA== +-----END CERTIFICATE-----`, + CapeTown: `-----BEGIN CERTIFICATE----- +MIICNjCCAZ+gAwIBAgIJAKumfZiRrNvHMA0GCSqGSIb3DQEBCwUAMFwxCzAJBgNV +BAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0 +dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzAgFw0xOTExMjcw +NzE0MDVaGA8yMTk5MDUwMjA3MTQwNVowXDELMAkGA1UEBhMCVVMxGTAXBgNVBAgT +EFdhc2hpbmd0b24gU3RhdGUxEDAOBgNVBAcTB1NlYXR0bGUxIDAeBgNVBAoTF0Ft +YXpvbiBXZWIgU2VydmljZXMgTExDMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB +gQDFd571nUzVtke3rPyRkYfvs3jh0C0EMzzG72boyUNjnfw1+m0TeFraTLKb9T6F +7TuB/ZEN+vmlYqr2+5Va8U8qLbPF0bRH+FdaKjhgWZdYXxGzQzU3ioy5W5ZM1VyB +7iUsxEAlxsybC3ziPYaHI42UiTkQNahmoroNeqVyHNnBpQIDAQABMA0GCSqGSIb3 +DQEBCwUAA4GBAAJLylWyElEgOpW4B1XPyRVD4pAds8Guw2+krgqkY0HxLCdjosuH +RytGDGN+q75aAoXzW5a7SGpxLxk6Hfv0xp3RjDHsoeP0i1d8MD3hAC5ezxS4oukK +s5gbPOnokhKTMPXbTdRn5ZifCbWlx+bYN/mTYKvxho7b5SVg2o1La9aK +-----END CERTIFICATE-----`, + Milan: `-----BEGIN CERTIFICATE----- +MIICNjCCAZ+gAwIBAgIJAOZ3GEIaDcugMA0GCSqGSIb3DQEBCwUAMFwxCzAJBgNV +BAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0 +dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzAgFw0xOTEwMjQx +NTE5MDlaGA8yMTk5MDMyOTE1MTkwOVowXDELMAkGA1UEBhMCVVMxGTAXBgNVBAgT +EFdhc2hpbmd0b24gU3RhdGUxEDAOBgNVBAcTB1NlYXR0bGUxIDAeBgNVBAoTF0Ft +YXpvbiBXZWIgU2VydmljZXMgTExDMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB +gQCjiPgW3vsXRj4JoA16WQDyoPc/eh3QBARaApJEc4nPIGoUolpAXcjFhWplo2O+ +ivgfCsc4AU9OpYdAPha3spLey/bhHPRi1JZHRNqScKP0hzsCNmKhfnZTIEQCFvsp +DRp4zr91/WS06/flJFBYJ6JHhp0KwM81XQG59lV6kkoW7QIDAQABMA0GCSqGSIb3 +DQEBCwUAA4GBAGLLrY3P+HH6C57dYgtJkuGZGT2+rMkk2n81/abzTJvsqRqGRrWv +XRKRXlKdM/dfiuYGokDGxiC0Mg6TYy6wvsR2qRhtXW1OtZkiHWcQCnOttz+8vpew +wx8JGMvowtuKB1iMsbwyRqZkFYLcvH+Opfb/Aayi20/ChQLdI6M2R5VU +-----END CERTIFICATE-----`, + China: `-----BEGIN CERTIFICATE----- +MIICSzCCAbQCCQCQu97teKRD4zANBgkqhkiG9w0BAQUFADBqMQswCQYDVQQGEwJV +UzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHU2VhdHRsZTEYMBYGA1UE +ChMPQW1hem9uLmNvbSBJbmMuMRowGAYDVQQDExFlYzIuYW1hem9uYXdzLmNvbTAe +Fw0xMzA4MjExMzIyNDNaFw0yMzA4MjExMzIyNDNaMGoxCzAJBgNVBAYTAlVTMRMw +EQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdTZWF0dGxlMRgwFgYDVQQKEw9B +bWF6b24uY29tIEluYy4xGjAYBgNVBAMTEWVjMi5hbWF6b25hd3MuY29tMIGfMA0G +CSqGSIb3DQEBAQUAA4GNADCBiQKBgQC6GFQ2WoBl1xZYH85INUMaTc4D30QXM6f+ +YmWZyJD9fC7Z0UlaZIKoQATqCO58KNCre+jECELYIX56Uq0lb8LRLP8tijrQ9Sp3 +qJcXiH66kH0eQ44a5YdewcFOy+CSAYDUIaB6XhTQJ2r7bd4A2vw3ybbxTOWONKdO +WtgIe3M3iwIDAQABMA0GCSqGSIb3DQEBBQUAA4GBAHzQC5XZVeuD9GTJTsbO5AyH +ZQvki/jfARNrD9dgBRYZzLC/NOkWG6M9wlrmks9RtdNxc53nLxKq4I2Dd73gI0yQ +wYu9YYwmM/LMgmPlI33Rg2Ohwq4DVgT3hO170PL6Fsgiq3dMvctSImJvjWktBQaT +bcAgaZLHGIpXPrWSA2d+ +-----END CERTIFICATE-----`, + GovCloud: `-----BEGIN CERTIFICATE----- +MIIDCzCCAnSgAwIBAgIJAIe9Hnq82O7UMA0GCSqGSIb3DQEBCwUAMFwxCzAJBgNV +BAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0 +dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzAeFw0yMTA3MTQx +NDI3NTdaFw0yNDA3MTMxNDI3NTdaMFwxCzAJBgNVBAYTAlVTMRkwFwYDVQQIExBX +YXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYDVQQKExdBbWF6 +b24gV2ViIFNlcnZpY2VzIExMQzCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA +qaIcGFFTx/SO1W5G91jHvyQdGP25n1Y91aXCuOOWAUTvSvNGpXrI4AXNrQF+CmIO +C4beBASnHCx082jYudWBBl9Wiza0psYc9flrczSzVLMmN8w/c78F/95NfiQdnUQP +pvgqcMeJo82cgHkLR7XoFWgMrZJqrcUK0gnsQcb6kakCAwEAAaOB1DCB0TALBgNV +HQ8EBAMCB4AwHQYDVR0OBBYEFNWV53gWJz72F5B1ZVY4O/dfFYBPMIGOBgNVHSME +gYYwgYOAFNWV53gWJz72F5B1ZVY4O/dfFYBPoWCkXjBcMQswCQYDVQQGEwJVUzEZ +MBcGA1UECBMQV2FzaGluZ3RvbiBTdGF0ZTEQMA4GA1UEBxMHU2VhdHRsZTEgMB4G +A1UEChMXQW1hem9uIFdlYiBTZXJ2aWNlcyBMTEOCCQCHvR56vNju1DASBgNVHRMB +Af8ECDAGAQH/AgEAMA0GCSqGSIb3DQEBCwUAA4GBACrKjWj460GUPZCGm3/z0dIz +M2BPuH769wcOsqfFZcMKEysSFK91tVtUb1soFwH4/Lb/T0PqNrvtEwD1Nva5k0h2 +xZhNNRmDuhOhW1K9wCcnHGRBwY5t4lYL6hNV6hcrqYwGMjTjcAjBG2yMgznSNFle +Rwi/S3BFXISixNx9cILu +-----END CERTIFICATE-----`, +} diff --git a/coderd/awsidentity/awsidentity_test.go b/coderd/awsidentity/awsidentity_test.go new file mode 100644 index 0000000000000..755079fc87b80 --- /dev/null +++ b/coderd/awsidentity/awsidentity_test.go @@ -0,0 +1,53 @@ +package awsidentity_test + +import ( + "crypto/rsa" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/awsidentity" +) + +const ( + signature = `M7rX9w1s5zK1V7hK0dsE4hTDXHHaaDuKQ9iIz/W8ZNaA2lJ/usz5YuX+ORt3luJwswl/+B7cYOkJ +bXRMx/pEQ6vT+niLGZDC9ZZ1h9Ox4h4e4m4IisQSCUrVIzyLj+MB27/Wyy0NhXcpoZVjNEmioxF2 +HNpOR4aCwUxxOm81y98=` + document = `{ + "accountId" : "628783029487", + "architecture" : "x86_64", + "availabilityZone" : "us-east-1b", + "billingProducts" : null, + "devpayProductCodes" : null, + "marketplaceProductCodes" : null, + "imageId" : "ami-0c02fb55956c7d316", + "instanceId" : "i-076e9b91f7c420782", + "instanceType" : "t2.micro", + "kernelId" : null, + "pendingTime" : "2022-03-25T20:07:16Z", + "privateIp" : "172.31.84.238", + "ramdiskId" : null, + "region" : "us-east-1", + "version" : "2017-09-30" +}` +) + +func TestValidate(t *testing.T) { + t.Parallel() + t.Run("FailEmpty", func(t *testing.T) { + t.Parallel() + _, err := awsidentity.Validate("", "", nil) + require.Error(t, err) + }) + t.Run("FailBad", func(t *testing.T) { + t.Parallel() + _, err := awsidentity.Validate(signature, "{}", nil) + require.ErrorIs(t, err, rsa.ErrVerification) + }) + t.Run("Success", func(t *testing.T) { + t.Parallel() + identity, err := awsidentity.Validate(signature, document, nil) + require.NoError(t, err) + require.Equal(t, awsidentity.Other, identity.Region) + }) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index e1137c6dd467e..32212ab1216cc 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -10,6 +10,7 @@ import ( "google.golang.org/api/idtoken" "cdr.dev/slog" + "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -24,6 +25,7 @@ type Options struct { Database database.Store Pubsub database.Pubsub + AWSCertificates awsidentity.Certificates GoogleTokenValidator *idtoken.Validator } @@ -135,6 +137,7 @@ func New(options *Options) (http.Handler, func()) { }) r.Route("/workspaceresources", func(r chi.Router) { r.Route("/auth", func(r chi.Router) { + r.Post("/aws-instance-identity", api.postWorkspaceAuthAWSInstanceIdentity) r.Post("/google-instance-identity", api.postWorkspaceAuthGoogleInstanceIdentity) }) r.Route("/agent", func(r chi.Router) { diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 72a2af177d468..48469c11952a2 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -3,11 +3,15 @@ package coderdtest import ( "bytes" "context" + "crypto" "crypto/rand" "crypto/rsa" + "crypto/sha256" + "crypto/x509" "database/sql" "encoding/base64" "encoding/json" + "encoding/pem" "io" "io/ioutil" "math/big" @@ -31,6 +35,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/databasefake" "github.com/coder/coder/coderd/database/postgres" @@ -43,7 +48,8 @@ import ( ) type Options struct { - GoogleTokenValidator *idtoken.Validator + AWSInstanceIdentity awsidentity.Certificates + GoogleInstanceIdentity *idtoken.Validator } // New constructs an in-memory coderd instance and returns @@ -52,11 +58,11 @@ func New(t *testing.T, options *Options) *codersdk.Client { if options == nil { options = &Options{} } - if options.GoogleTokenValidator == nil { + if options.GoogleInstanceIdentity == nil { ctx, cancelFunc := context.WithCancel(context.Background()) t.Cleanup(cancelFunc) var err error - options.GoogleTokenValidator, err = idtoken.NewValidator(ctx, option.WithoutAuthentication()) + options.GoogleInstanceIdentity, err = idtoken.NewValidator(ctx, option.WithoutAuthentication()) require.NoError(t, err) } @@ -101,7 +107,8 @@ func New(t *testing.T, options *Options) *codersdk.Client { Database: db, Pubsub: pubsub, - GoogleTokenValidator: options.GoogleTokenValidator, + AWSCertificates: options.AWSInstanceIdentity, + GoogleTokenValidator: options.GoogleInstanceIdentity, }) t.Cleanup(func() { srv.Close() @@ -334,6 +341,65 @@ func NewGoogleInstanceIdentity(t *testing.T, instanceID string, expired bool) (* }) } +// NewAWSInstanceIdentity returns a metadata client and ID token validator for faking +// instance authentication for AWS. +func NewAWSInstanceIdentity(t *testing.T, instanceID string) (awsidentity.Certificates, *http.Client) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + document := []byte(`{"instanceId":"` + instanceID + `"}`) + documentHash := sha256.New() + _, err = documentHash.Write(document) + require.NoError(t, err) + hashedDocument := documentHash.Sum(nil) + + signatureRaw, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashedDocument) + require.NoError(t, err) + signature := make([]byte, base64.StdEncoding.EncodedLen(len(signatureRaw))) + base64.StdEncoding.Encode(signature, signatureRaw) + + certificate, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(2022), + }, &x509.Certificate{}, &privateKey.PublicKey, privateKey) + require.NoError(t, err) + + certificatePEM := bytes.Buffer{} + err = pem.Encode(&certificatePEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certificate, + }) + require.NoError(t, err) + + return awsidentity.Certificates{ + awsidentity.Other: certificatePEM.String(), + }, &http.Client{ + Transport: roundTripper(func(r *http.Request) (*http.Response, error) { + switch r.URL.Path { + case "/latest/api/token": + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader([]byte("faketoken"))), + Header: make(http.Header), + }, nil + case "/latest/dynamic/instance-identity/signature": + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader(signature)), + Header: make(http.Header), + }, nil + case "/latest/dynamic/instance-identity/document": + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader(document)), + Header: make(http.Header), + }, nil + default: + panic("unhandled route: " + r.URL.Path) + } + }), + } +} + func randomUsername() string { return strings.ReplaceAll(namesgenerator.GetRandomName(0), "_", "-") } diff --git a/coderd/coderdtest/coderdtest_test.go b/coderd/coderdtest/coderdtest_test.go index 765fd3d0e451f..7eb8719ea2020 100644 --- a/coderd/coderdtest/coderdtest_test.go +++ b/coderd/coderdtest/coderdtest_test.go @@ -24,5 +24,6 @@ func TestNew(t *testing.T) { coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) _, _ = coderdtest.NewGoogleInstanceIdentity(t, "example", false) + _, _ = coderdtest.NewAWSInstanceIdentity(t, "an-instance") closer.Close() } diff --git a/coderd/workspaceresourceauth.go b/coderd/workspaceresourceauth.go index 14a8742f796d2..92b240fba2230 100644 --- a/coderd/workspaceresourceauth.go +++ b/coderd/workspaceresourceauth.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/render" + "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/codersdk" @@ -16,6 +17,24 @@ import ( "github.com/mitchellh/mapstructure" ) +// AWS supports instance identity verification: +// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html +// Using this, we can exchange a signed instance payload for an agent token. +func (api *api) postWorkspaceAuthAWSInstanceIdentity(rw http.ResponseWriter, r *http.Request) { + var req codersdk.AWSInstanceIdentityToken + if !httpapi.Read(rw, r, &req) { + return + } + identity, err := awsidentity.Validate(req.Signature, req.Document, api.AWSCertificates) + if err != nil { + httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ + Message: fmt.Sprintf("validate: %s", err), + }) + return + } + api.handleAuthInstanceID(rw, r, identity.InstanceID) +} + // Google Compute Engine supports instance identity verification: // https://cloud.google.com/compute/docs/instances/verifying-instance-identity // Using this, we can exchange a signed instance payload for an agent token. @@ -47,10 +66,14 @@ func (api *api) postWorkspaceAuthGoogleInstanceIdentity(rw http.ResponseWriter, }) return } - agent, err := api.Database.GetWorkspaceAgentByInstanceID(r.Context(), claims.Google.ComputeEngine.InstanceID) + api.handleAuthInstanceID(rw, r, claims.Google.ComputeEngine.InstanceID) +} + +func (api *api) handleAuthInstanceID(rw http.ResponseWriter, r *http.Request, instanceID string) { + agent, err := api.Database.GetWorkspaceAgentByInstanceID(r.Context(), instanceID) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ - Message: fmt.Sprintf("instance with id %q not found", claims.Google.ComputeEngine.InstanceID), + Message: fmt.Sprintf("instance with id %q not found", instanceID), }) return } @@ -107,7 +130,7 @@ func (api *api) postWorkspaceAuthGoogleInstanceIdentity(rw http.ResponseWriter, } if latestHistory.ID.String() != resourceHistory.ID.String() { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: fmt.Sprintf("resource found for id %q, but isn't registered on the latest history", claims.Google.ComputeEngine.InstanceID), + Message: fmt.Sprintf("resource found for id %q, but isn't registered on the latest history", instanceID), }) return } diff --git a/coderd/workspaceresourceauth_test.go b/coderd/workspaceresourceauth_test.go index e8f844d5edb87..a38ecdbf4a9a9 100644 --- a/coderd/workspaceresourceauth_test.go +++ b/coderd/workspaceresourceauth_test.go @@ -13,6 +13,45 @@ import ( "github.com/coder/coder/provisionersdk/proto" ) +func TestPostWorkspaceAuthAWSInstanceIdentity(t *testing.T) { + t.Parallel() + t.Run("Success", func(t *testing.T) { + t.Parallel() + instanceID := "instanceidentifier" + certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID) + client := coderdtest.New(t, &coderdtest.Options{ + AWSInstanceIdentity: certificates, + }) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "somename", + Type: "someinstance", + Agent: &proto.Agent{ + Auth: &proto.Agent_InstanceId{ + InstanceId: instanceID, + }, + }, + }}, + }, + }, + }}, + }) + project := coderdtest.CreateProject(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitProjectVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, "me", project.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + _, err := client.AuthWorkspaceAWSInstanceIdentity(context.Background(), metadataClient) + require.NoError(t, err) + }) +} + func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { t.Parallel() t.Run("Expired", func(t *testing.T) { @@ -20,7 +59,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { instanceID := "instanceidentifier" validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, true) client := coderdtest.New(t, &coderdtest.Options{ - GoogleTokenValidator: validator, + GoogleInstanceIdentity: validator, }) _, err := client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", metadata) var apiErr *codersdk.Error @@ -33,7 +72,7 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { instanceID := "instanceidentifier" validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) client := coderdtest.New(t, &coderdtest.Options{ - GoogleTokenValidator: validator, + GoogleInstanceIdentity: validator, }) _, err := client.AuthWorkspaceGoogleInstanceIdentity(context.Background(), "", metadata) var apiErr *codersdk.Error @@ -46,27 +85,12 @@ func TestPostWorkspaceAuthGoogleInstanceIdentity(t *testing.T) { instanceID := "instanceidentifier" validator, metadata := coderdtest.NewGoogleInstanceIdentity(t, instanceID, false) client := coderdtest.New(t, &coderdtest.Options{ - GoogleTokenValidator: validator, + GoogleInstanceIdentity: validator, }) user := coderdtest.CreateFirstUser(t, client) coderdtest.NewProvisionerDaemon(t, client) version := coderdtest.CreateProjectVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, - ProvisionDryRun: []*proto.Provision_Response{{ - Type: &proto.Provision_Response_Complete{ - Complete: &proto.Provision_Complete{ - Resources: []*proto.Resource{{ - Name: "somename", - Type: "someinstance", - Agent: &proto.Agent{ - Auth: &proto.Agent_InstanceId{ - InstanceId: "", - }, - }, - }}, - }, - }, - }}, Provision: []*proto.Provision_Response{{ Type: &proto.Provision_Response_Complete{ Complete: &proto.Provision_Complete{ diff --git a/codersdk/workspaceresourceauth.go b/codersdk/workspaceresourceauth.go index 9c4673f3a6237..86153bb63f6d3 100644 --- a/codersdk/workspaceresourceauth.go +++ b/codersdk/workspaceresourceauth.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "cloud.google.com/go/compute/metadata" @@ -14,6 +15,11 @@ type GoogleInstanceIdentityToken struct { JSONWebToken string `json:"json_web_token" validate:"required"` } +type AWSInstanceIdentityToken struct { + Signature string `json:"signature" validate:"required"` + Document string `json:"document" validate:"required"` +} + // WorkspaceAgentAuthenticateResponse is returned when an instance ID // has been exchanged for a session token. type WorkspaceAgentAuthenticateResponse struct { @@ -50,3 +56,71 @@ func (c *Client) AuthWorkspaceGoogleInstanceIdentity(ctx context.Context, servic var resp WorkspaceAgentAuthenticateResponse return resp, json.NewDecoder(res.Body).Decode(&resp) } + +// AuthWorkspaceAWSInstanceIdentity uses the Amazon Metadata API to +// fetch a signed payload, and exchange it for a session token for a workspace agent. +// +// The requesting instance must be registered as a resource in the latest history for a workspace. +func (c *Client) AuthWorkspaceAWSInstanceIdentity(ctx context.Context, metadataClient *http.Client) (WorkspaceAgentAuthenticateResponse, error) { + if metadataClient == nil { + metadataClient = c.HTTPClient + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://169.254.169.254/latest/api/token", nil) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, nil + } + req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600") + res, err := metadataClient.Do(req) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, err + } + defer res.Body.Close() + token, err := io.ReadAll(res.Body) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err) + } + + req, err = http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/latest/dynamic/instance-identity/signature", nil) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, nil + } + req.Header.Set("X-aws-ec2-metadata-token", string(token)) + res, err = metadataClient.Do(req) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, err + } + defer res.Body.Close() + signature, err := io.ReadAll(res.Body) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err) + } + + req, err = http.NewRequestWithContext(ctx, http.MethodGet, "http://169.254.169.254/latest/dynamic/instance-identity/document", nil) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, nil + } + req.Header.Set("X-aws-ec2-metadata-token", string(token)) + res, err = metadataClient.Do(req) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, err + } + defer res.Body.Close() + document, err := io.ReadAll(res.Body) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, xerrors.Errorf("read token: %w", err) + } + + res, err = c.request(ctx, http.MethodPost, "/api/v2/workspaceresources/auth/aws-instance-identity", AWSInstanceIdentityToken{ + Signature: string(signature), + Document: string(document), + }) + if err != nil { + return WorkspaceAgentAuthenticateResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceAgentAuthenticateResponse{}, readBodyAsError(res) + } + var resp WorkspaceAgentAuthenticateResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} From 87cd77cf5b0a4d3540a93058b9335f7c5f50902c Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 26 Mar 2022 05:04:13 +0000 Subject: [PATCH 2/3] feat: Support caching provisioner assets This caches the Terraform binary, and Terraform plugins. Eventually, it could cache other temporary files. --- cli/start.go | 15 ++++++++++++--- coder.service | 1 + provisioner/terraform/provision.go | 8 ++++++++ provisioner/terraform/serve.go | 8 ++++++-- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/cli/start.go b/cli/start.go index 00e4996dc6905..6194eb683a5de 100644 --- a/cli/start.go +++ b/cli/start.go @@ -13,6 +13,7 @@ import ( "net/url" "os" "os/signal" + "path/filepath" "strconv" "time" @@ -42,6 +43,7 @@ func start() *cobra.Command { var ( accessURL string address string + cacheDir string dev bool postgresURL string provisionerDaemonCount uint8 @@ -164,7 +166,7 @@ func start() *cobra.Command { provisionerDaemons := make([]*provisionerd.Server, 0) for i := uint8(0); i < provisionerDaemonCount; i++ { - daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger) + daemonClose, err := newProvisionerDaemon(cmd.Context(), client, logger, cacheDir) if err != nil { return xerrors.Errorf("create provisioner daemon: %w", err) } @@ -312,6 +314,12 @@ func start() *cobra.Command { } root.Flags().StringVarP(&accessURL, "access-url", "", os.Getenv("CODER_ACCESS_URL"), "Specifies the external URL to access Coder (uses $CODER_ACCESS_URL).") root.Flags().StringVarP(&address, "address", "a", defaultAddress, "The address to serve the API and dashboard (uses $CODER_ADDRESS).") + // systemd uses the CACHE_DIRECTORY environment variable! + defaultCacheDir := os.Getenv("CACHE_DIRECTORY") + if defaultCacheDir == "" { + defaultCacheDir = filepath.Join(os.TempDir(), ".coder-cache") + } + root.Flags().StringVarP(&cacheDir, "cache-dir", "", defaultCacheDir, "Specify a directory to cache binaries for provision operations.") defaultDev, _ := strconv.ParseBool(os.Getenv("CODER_DEV_MODE")) root.Flags().BoolVarP(&dev, "dev", "", defaultDev, "Serve Coder in dev mode for tinkering (uses $CODER_DEV_MODE).") root.Flags().StringVarP(&postgresURL, "postgres-url", "", "", @@ -381,14 +389,15 @@ func createFirstUser(cmd *cobra.Command, client *codersdk.Client, cfg config.Roo return nil } -func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger) (*provisionerd.Server, error) { +func newProvisionerDaemon(ctx context.Context, client *codersdk.Client, logger slog.Logger, cacheDir string) (*provisionerd.Server, error) { terraformClient, terraformServer := provisionersdk.TransportPipe() go func() { err := terraform.Serve(ctx, &terraform.ServeOptions{ ServeOptions: &provisionersdk.ServeOptions{ Listener: terraformServer, }, - Logger: logger, + CachePath: cacheDir, + Logger: logger, }) if err != nil { panic(err) diff --git a/coder.service b/coder.service index 9e1952688b206..3fc9a01f1e588 100644 --- a/coder.service +++ b/coder.service @@ -16,6 +16,7 @@ PrivateTmp=yes PrivateDevices=yes SecureBits=keep-caps AmbientCapabilities=CAP_IPC_LOCK +CacheDirectory=coder CapabilityBoundingSet=CAP_SYSLOG CAP_IPC_LOCK CAP_NET_BIND_SERVICE NoNewPrivileges=yes ExecStart=/usr/bin/coder start diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index b321d4e6f6131..67df5a08df5b9 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -87,6 +87,14 @@ func (t *terraform) Provision(stream proto.DRPCProvisioner_ProvisionStream) erro }) } }() + if t.cachePath != "" { + err = terraform.SetEnv(map[string]string{ + "TF_PLUGIN_CACHE_DIR": t.cachePath, + }) + if err != nil { + return xerrors.Errorf("set terraform plugin cache dir: %w", err) + } + } terraform.SetStdout(writer) t.logger.Debug(shutdown, "running initialization") err = terraform.Init(shutdown) diff --git a/provisioner/terraform/serve.go b/provisioner/terraform/serve.go index bda2944ea0701..ef8f039d51412 100644 --- a/provisioner/terraform/serve.go +++ b/provisioner/terraform/serve.go @@ -34,6 +34,7 @@ type ServeOptions struct { // BinaryPath specifies the "terraform" binary to use. // If omitted, the $PATH will attempt to find it. BinaryPath string + CachePath string Logger slog.Logger } @@ -43,8 +44,9 @@ func Serve(ctx context.Context, options *ServeOptions) error { binaryPath, err := exec.LookPath("terraform") if err != nil { installer := &releases.ExactVersion{ - Product: product.Terraform, - Version: version.Must(version.NewVersion("1.1.7")), + InstallDir: options.CachePath, + Product: product.Terraform, + Version: version.Must(version.NewVersion("1.1.7")), } execPath, err := installer.Install(ctx) @@ -58,11 +60,13 @@ func Serve(ctx context.Context, options *ServeOptions) error { } return provisionersdk.Serve(ctx, &terraform{ binaryPath: options.BinaryPath, + cachePath: options.CachePath, logger: options.Logger, }, options.ServeOptions) } type terraform struct { binaryPath string + cachePath string logger slog.Logger } From 064eb81b3e88a25c7c26e92414025630a93a0375 Mon Sep 17 00:00:00 2001 From: Garrett Date: Mon, 28 Mar 2022 19:35:32 +0000 Subject: [PATCH 3/3] chore: fix linter --- cli/cliflag/cliflag_test.go | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/cli/cliflag/cliflag_test.go b/cli/cliflag/cliflag_test.go index 542bb04abfd9d..2228b7e10bbc9 100644 --- a/cli/cliflag/cliflag_test.go +++ b/cli/cliflag/cliflag_test.go @@ -16,11 +16,11 @@ import ( //nolint:paralleltest func TestCliflag(t *testing.T) { t.Run("StringDefault", func(t *testing.T) { - var p string + var ptr string flagset, name, shorthand, env, usage := randomFlag() def, _ := cryptorand.String(10) - cliflag.StringVarP(flagset, &p, name, shorthand, env, def, usage) + cliflag.StringVarP(flagset, &ptr, name, shorthand, env, def, usage) got, err := flagset.GetString(name) require.NoError(t, err) require.Equal(t, def, got) @@ -29,24 +29,24 @@ func TestCliflag(t *testing.T) { }) t.Run("StringEnvVar", func(t *testing.T) { - var p string + var ptr string flagset, name, shorthand, env, usage := randomFlag() envValue, _ := cryptorand.String(10) t.Setenv(env, envValue) def, _ := cryptorand.String(10) - cliflag.StringVarP(flagset, &p, name, shorthand, env, def, usage) + cliflag.StringVarP(flagset, &ptr, name, shorthand, env, def, usage) got, err := flagset.GetString(name) require.NoError(t, err) require.Equal(t, envValue, got) }) t.Run("EmptyEnvVar", func(t *testing.T) { - var p string + var ptr string flagset, name, shorthand, _, usage := randomFlag() def, _ := cryptorand.String(10) - cliflag.StringVarP(flagset, &p, name, shorthand, "", def, usage) + cliflag.StringVarP(flagset, &ptr, name, shorthand, "", def, usage) got, err := flagset.GetString(name) require.NoError(t, err) require.Equal(t, def, got) @@ -55,11 +55,11 @@ func TestCliflag(t *testing.T) { }) t.Run("IntDefault", func(t *testing.T) { - var p uint8 + var ptr uint8 flagset, name, shorthand, env, usage := randomFlag() def, _ := cryptorand.Int63n(10) - cliflag.Uint8VarP(flagset, &p, name, shorthand, env, uint8(def), usage) + cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage) got, err := flagset.GetUint8(name) require.NoError(t, err) require.Equal(t, uint8(def), got) @@ -68,37 +68,37 @@ func TestCliflag(t *testing.T) { }) t.Run("IntEnvVar", func(t *testing.T) { - var p uint8 + var ptr uint8 flagset, name, shorthand, env, usage := randomFlag() envValue, _ := cryptorand.Int63n(10) t.Setenv(env, strconv.FormatUint(uint64(envValue), 10)) def, _ := cryptorand.Int() - cliflag.Uint8VarP(flagset, &p, name, shorthand, env, uint8(def), usage) + cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage) got, err := flagset.GetUint8(name) require.NoError(t, err) require.Equal(t, uint8(envValue), got) }) t.Run("IntFailParse", func(t *testing.T) { - var p uint8 + var ptr uint8 flagset, name, shorthand, env, usage := randomFlag() envValue, _ := cryptorand.String(10) t.Setenv(env, envValue) def, _ := cryptorand.Int63n(10) - cliflag.Uint8VarP(flagset, &p, name, shorthand, env, uint8(def), usage) + cliflag.Uint8VarP(flagset, &ptr, name, shorthand, env, uint8(def), usage) got, err := flagset.GetUint8(name) require.NoError(t, err) require.Equal(t, uint8(def), got) }) t.Run("BoolDefault", func(t *testing.T) { - var p bool + var ptr bool flagset, name, shorthand, env, usage := randomFlag() def, _ := cryptorand.Bool() - cliflag.BoolVarP(flagset, &p, name, shorthand, env, def, usage) + cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage) got, err := flagset.GetBool(name) require.NoError(t, err) require.Equal(t, def, got) @@ -107,26 +107,26 @@ func TestCliflag(t *testing.T) { }) t.Run("BoolEnvVar", func(t *testing.T) { - var p bool + var ptr bool flagset, name, shorthand, env, usage := randomFlag() envValue, _ := cryptorand.Bool() t.Setenv(env, strconv.FormatBool(envValue)) def, _ := cryptorand.Bool() - cliflag.BoolVarP(flagset, &p, name, shorthand, env, def, usage) + cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage) got, err := flagset.GetBool(name) require.NoError(t, err) require.Equal(t, envValue, got) }) t.Run("BoolFailParse", func(t *testing.T) { - var p bool + var ptr bool flagset, name, shorthand, env, usage := randomFlag() envValue, _ := cryptorand.String(10) t.Setenv(env, envValue) def, _ := cryptorand.Bool() - cliflag.BoolVarP(flagset, &p, name, shorthand, env, def, usage) + cliflag.BoolVarP(flagset, &ptr, name, shorthand, env, def, usage) got, err := flagset.GetBool(name) require.NoError(t, err) require.Equal(t, def, got)