From 56e56969ebe0b7057fe3193e2296418779bf9f30 Mon Sep 17 00:00:00 2001 From: Curtis Date: Wed, 28 Oct 2020 12:43:13 -0700 Subject: [PATCH] ECS Credential Provider Emulation (#11) * ECS metadata service support * Handle credential expiry ; add readme * Minor readme change --- README.md | 35 ++++++++++++++++ cmd/ecs_credential_provider.go | 56 +++++++++++++++++++++++++ consoleme/consoleme.go | 52 +++++++++++++++++++++++ consoleme/types.go | 1 + handlers/ecsCredentialsHandler.go | 68 +++++++++++++++++++++++++++++++ handlers/middleware.go | 2 +- metadata/metadata.go | 47 +-------------------- metadata/types.go | 8 ++++ 8 files changed, 222 insertions(+), 47 deletions(-) create mode 100644 cmd/ecs_credential_provider.go create mode 100644 handlers/ecsCredentialsHandler.go diff --git a/README.md b/README.md index aba1f7e..acbdf4d 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,43 @@ sudo /sbin/iptables-restore < .txt ## Usage +### ECS Credential Provider (Recommended) + +Weep supports emulating the ECS credential provider to provide credentials to your AWS SDK. This solution can be +minimally configured by setting the `AWS_CONTAINER_CREDENTIALS_FULL_URI` environment variable for your process. There's +no need for iptables or routing rules with this approach, and each different shell or process can use weep to request +credentials for different roles. Weep will cache the credentials you request in-memory, and will refresh them on-demand +when they are within 10 minutes of expiring. + +In one shell, run weep: + +```bash +weep ecs_credential_provider +``` + +In your favorite IDE or shell, set the `AWS_CONTAINER_CREDENTIALS_FULL_URI` environment variable and run AWS commands. + +```bash +AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:9090/ecs/consoleme_oss_1 aws sts get-caller-identity +{ + "UserId": "AROA4JEFLERSKVPFT4INI:user@example.com", + "Account": "123456789012", + "Arn": "arn:aws:sts::123456789012:assumed-role/consoleme_oss_1_test_user/user@example.com" +} + +AWS_CONTAINER_CREDENTIALS_FULL_URI=http://localhost:9090/ecs/consoleme_oss_2 aws sts get-caller-identity +{ + "UserId": "AROA6KW3MOV2F7J6AT4PC:user@example.com", + "Account": "223456789012", + "Arn": "arn:aws:sts::223456789012:assumed-role/consoleme_oss_2_test_user/user@example.com" +} +``` + ### Metadata Proxy +Weep supports emulating the instance metadata service. This requires that you have iptables DNAT rules configured, and +it only serves one role per weep process. + ```bash # You can use a full ARN weep metadata arn:aws:iam::123456789012:role/exampleRole diff --git a/cmd/ecs_credential_provider.go b/cmd/ecs_credential_provider.go new file mode 100644 index 0000000..c890b24 --- /dev/null +++ b/cmd/ecs_credential_provider.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "fmt" + "net" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/gorilla/mux" + "github.com/netflix/weep/handlers" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func init() { + ecsCredentialProvider.PersistentFlags().StringVarP(&metadataListenAddr, "listen-address", "a", "127.0.0.1", "IP address for the ECS credential provider to listen on") + ecsCredentialProvider.PersistentFlags().IntVarP(&metadataListenPort, "port", "p", 9090, "port for the ECS credential provider service to listen on") + rootCmd.AddCommand(ecsCredentialProvider) +} + +var ecsCredentialProvider = &cobra.Command{ + Use: "ecs_credential_provider", + Short: "Run a local ECS Credential Provider endpoint that serves and caches credentials for roles on demand", + RunE: runEcsMetadata, +} + +func runEcsMetadata(cmd *cobra.Command, args []string) error { + ipaddress := net.ParseIP(metadataListenAddr) + + if ipaddress == nil { + fmt.Println("Invalid IP: ", metadataListenAddr) + os.Exit(1) + } + + listenAddr := fmt.Sprintf("%s:%d", ipaddress, metadataListenPort) + + router := mux.NewRouter() + router.HandleFunc("/ecs/{role:.*}", handlers.MetaDataServiceMiddleware(handlers.ECSMetadataServiceCredentialsHandler)) + router.HandleFunc("/{path:.*}", handlers.MetaDataServiceMiddleware(handlers.CustomHandler)) + + go func() { + log.Info("Starting weep ECS meta-data service...") + log.Info("Server started on: ", listenAddr) + log.Info(http.ListenAndServe(listenAddr, router)) + }() + + // Check for interrupt signal and exit cleanly + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Print("Shutdown signal received, exiting weep...") + + return nil +} diff --git a/consoleme/consoleme.go b/consoleme/consoleme.go index 8ce0bb4..3e2facb 100644 --- a/consoleme/consoleme.go +++ b/consoleme/consoleme.go @@ -4,12 +4,19 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + AwsSdkCredentials "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/netflix/weep/util" "io" "io/ioutil" "net" "net/http" "net/url" "runtime" + "strings" "syscall" "time" @@ -217,9 +224,54 @@ func (c *Client) GetRoleCredentials(role string, ipRestrict bool) (AwsCredential return credentials.Credentials, errors.Wrap(err, "failed to unmarshal JSON") } + credentials.Credentials.RoleArn, err = getRoleArnFromCredentials(credentials.Credentials) + return credentials.Credentials, nil } +func getRoleArnFromCredentials(credentials AwsCredentials) (string, error) { + sess, err := session.NewSession(&aws.Config{ + Credentials: AwsSdkCredentials.NewStaticCredentials( + credentials.AccessKeyId, + credentials.SecretAccessKey, + credentials.SessionToken), + }) + util.CheckError(err) + svc := sts.New(sess) + input := &sts.GetCallerIdentityInput{} + + result, err := svc.GetCallerIdentity(input) + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + switch aerr.Code() { + default: + fmt.Println(aerr.Error()) + } + } else { + // Print the error, cast err to awserr.Error to get the Code and + // Message from an error. + fmt.Println(err.Error()) + } + return "", err + } + // Replace assumed role ARN with role ARN, if possible + // arn:aws:sts::123456789012:assumed-role/exampleInstanceProfile/user@example.com -> + // arn:aws:iam::123456789012:role/exampleInstanceProfile + Role := strings.Replace(*result.Arn, ":sts:", ":iam:", 1) + Role = strings.Replace(Role, ":assumed-role/", ":role/", 1) + // result.UserId looks like AROAIEBAVBLAH:user@example.com + splittedUserId := strings.Split(*result.UserId, ":") + if len(splittedUserId) > 1 { + sessionName := splittedUserId[1] + Role = strings.Replace( + Role, + fmt.Sprintf("/%s", sessionName), + "", + 1) + } + return Role, nil +} + func defaultTransport() *http.Transport { return &http.Transport{ Proxy: http.ProxyFromEnvironment, diff --git a/consoleme/types.go b/consoleme/types.go index 6256290..8d30b8c 100644 --- a/consoleme/types.go +++ b/consoleme/types.go @@ -5,6 +5,7 @@ type AwsCredentials struct { SecretAccessKey string `json:"SecretAccessKey"` SessionToken string `json:"SessionToken"` Expiration int64 `json:"Expiration"` + RoleArn string `json:"RoleArn"` } type CredentialProcess struct { diff --git a/handlers/ecsCredentialsHandler.go b/handlers/ecsCredentialsHandler.go new file mode 100644 index 0000000..2329e02 --- /dev/null +++ b/handlers/ecsCredentialsHandler.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/gorilla/mux" + "github.com/netflix/weep/consoleme" + "github.com/netflix/weep/metadata" + log "github.com/sirupsen/logrus" + "net/http" + "time" +) + +var credentialMap = make(map[string]consoleme.AwsCredentials) + +func ECSMetadataServiceCredentialsHandler(w http.ResponseWriter, r *http.Request) { + var client, err = consoleme.GetClient() + if err != nil { + log.Error(err) + return + } + vars := mux.Vars(r) + requestedRole := vars["role"] + var Credentials consoleme.AwsCredentials + + val, ok := credentialMap[requestedRole] + if ok { + Credentials = val + + // Refresh credentials on demand if expired or within 10 minutes of expiry + currentTime := time.Now() + tm := time.Unix(Credentials.Expiration, 0) + timeToRenew := tm.Add(-10 * time.Minute) + if currentTime.After(timeToRenew) { + Credentials, err = client.GetRoleCredentials(requestedRole, false) + if err != nil { + log.Error(err) + return + } + } + } else { + Credentials, err = client.GetRoleCredentials(requestedRole, false) + if err != nil { + log.Error(err) + return + } + credentialMap[requestedRole] = Credentials + } + + tm := time.Unix(Credentials.Expiration, 0) + + credentials := metadata.ECSMetaDataCredentialResponse{ + AccessKeyId: fmt.Sprintf("%s", Credentials.AccessKeyId), + Expiration: tm.UTC().Format("2006-01-02T15:04:05Z"), + RoleArn: Credentials.RoleArn, + SecretAccessKey: fmt.Sprintf("%s", Credentials.SecretAccessKey), + Token: fmt.Sprintf("%s", Credentials.SessionToken), + } + + b, err := json.Marshal(credentials) + if err != nil { + log.Error(err) + } + var out bytes.Buffer + json.Indent(&out, b, "", " ") + fmt.Fprintln(w, out.String()) +} diff --git a/handlers/middleware.go b/handlers/middleware.go index 49b6479..69197cc 100644 --- a/handlers/middleware.go +++ b/handlers/middleware.go @@ -34,7 +34,7 @@ func MetaDataServiceMiddleware(next http.HandlerFunc) http.HandlerFunc { "user-agent": ua, "path": r.URL.Path, "metadata_version": metadataVersion, - }).Info("You are using a SDK that does not support User-Agents that Netflix wants") + }).Info("You are using a SDK that is not passing an appropriate AWS User-Agent") } else { log.WithFields(log.Fields{ "user-agent": ua, diff --git a/metadata/metadata.go b/metadata/metadata.go index 339eb03..e07016c 100644 --- a/metadata/metadata.go +++ b/metadata/metadata.go @@ -1,15 +1,8 @@ package metadata import ( - "fmt" - "strings" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/awserr" - "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/sts" "github.com/netflix/weep/util" log "github.com/sirupsen/logrus" @@ -32,45 +25,7 @@ func StartMetaDataRefresh(client *consoleme.Client) { // TODO: If 403 response, MetaDataCredentials, err = client.GetRoleCredentials(Role, NoIpRestrict) util.CheckError(err) - sess, err := session.NewSession(&aws.Config{ - Credentials: credentials.NewStaticCredentials( - MetaDataCredentials.AccessKeyId, - MetaDataCredentials.SecretAccessKey, - MetaDataCredentials.SessionToken), - }) - util.CheckError(err) - svc := sts.New(sess) - input := &sts.GetCallerIdentityInput{} - - result, err := svc.GetCallerIdentity(input) - if err != nil { - if aerr, ok := err.(awserr.Error); ok { - switch aerr.Code() { - default: - fmt.Println(aerr.Error()) - } - } else { - // Print the error, cast err to awserr.Error to get the Code and - // Message from an error. - fmt.Println(err.Error()) - } - return - } - // Replace assumed role ARN with role ARN, if possible - // arn:aws:sts::123456789012:assumed-role/exampleInstanceProfile/user@example.com -> - // arn:aws:iam::123456789012:role/exampleInstanceProfile - Role = strings.Replace(*result.Arn, ":sts:", ":iam:", 1) - Role = strings.Replace(Role, ":assumed-role/", ":role/", 1) - // result.UserId looks like AROAIEBAVBLAH:user@example.com - splittedUserId := strings.Split(*result.UserId, ":") - if len(splittedUserId) > 1 { - sessionName := splittedUserId[1] - Role = strings.Replace( - Role, - fmt.Sprintf("/%s", sessionName), - "", - 1) - } + Role = MetaDataCredentials.RoleArn if err != nil { log.Error(err) time.Sleep(retryDelay) diff --git a/metadata/types.go b/metadata/types.go index 149bfec..4d0161f 100644 --- a/metadata/types.go +++ b/metadata/types.go @@ -12,6 +12,14 @@ type MetaDataCredentialResponse struct { Expiration string } +type ECSMetaDataCredentialResponse struct { + AccessKeyId string + SecretAccessKey string + Token string + Expiration string + RoleArn string +} + type MetaDataIamInfoResponse struct { Code string `json:"Code"` LastUpdated string `json:"LastUpdated"`