provider.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. package ec2rolecreds
  2. import (
  3. "bufio"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "math"
  8. "path"
  9. "strings"
  10. "time"
  11. "github.com/aws/aws-sdk-go-v2/aws"
  12. "github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
  13. sdkrand "github.com/aws/aws-sdk-go-v2/internal/rand"
  14. "github.com/aws/aws-sdk-go-v2/internal/sdk"
  15. "github.com/aws/smithy-go"
  16. "github.com/aws/smithy-go/logging"
  17. "github.com/aws/smithy-go/middleware"
  18. )
  19. // ProviderName provides a name of EC2Role provider
  20. const ProviderName = "EC2RoleProvider"
  21. // GetMetadataAPIClient provides the interface for an EC2 IMDS API client for the
  22. // GetMetadata operation.
  23. type GetMetadataAPIClient interface {
  24. GetMetadata(context.Context, *imds.GetMetadataInput, ...func(*imds.Options)) (*imds.GetMetadataOutput, error)
  25. }
  26. // A Provider retrieves credentials from the EC2 service, and keeps track if
  27. // those credentials are expired.
  28. //
  29. // The New function must be used to create the with a custom EC2 IMDS client.
  30. //
  31. // p := &ec2rolecreds.New(func(o *ec2rolecreds.Options{
  32. // o.Client = imds.New(imds.Options{/* custom options */})
  33. // })
  34. type Provider struct {
  35. options Options
  36. }
  37. // Options is a list of user settable options for setting the behavior of the Provider.
  38. type Options struct {
  39. // The API client that will be used by the provider to make GetMetadata API
  40. // calls to EC2 IMDS.
  41. //
  42. // If nil, the provider will default to the EC2 IMDS client.
  43. Client GetMetadataAPIClient
  44. }
  45. // New returns an initialized Provider value configured to retrieve
  46. // credentials from EC2 Instance Metadata service.
  47. func New(optFns ...func(*Options)) *Provider {
  48. options := Options{}
  49. for _, fn := range optFns {
  50. fn(&options)
  51. }
  52. if options.Client == nil {
  53. options.Client = imds.New(imds.Options{})
  54. }
  55. return &Provider{
  56. options: options,
  57. }
  58. }
  59. // Retrieve retrieves credentials from the EC2 service. Error will be returned
  60. // if the request fails, or unable to extract the desired credentials.
  61. func (p *Provider) Retrieve(ctx context.Context) (aws.Credentials, error) {
  62. credsList, err := requestCredList(ctx, p.options.Client)
  63. if err != nil {
  64. return aws.Credentials{Source: ProviderName}, err
  65. }
  66. if len(credsList) == 0 {
  67. return aws.Credentials{Source: ProviderName},
  68. fmt.Errorf("unexpected empty EC2 IMDS role list")
  69. }
  70. credsName := credsList[0]
  71. roleCreds, err := requestCred(ctx, p.options.Client, credsName)
  72. if err != nil {
  73. return aws.Credentials{Source: ProviderName}, err
  74. }
  75. creds := aws.Credentials{
  76. AccessKeyID: roleCreds.AccessKeyID,
  77. SecretAccessKey: roleCreds.SecretAccessKey,
  78. SessionToken: roleCreds.Token,
  79. Source: ProviderName,
  80. CanExpire: true,
  81. Expires: roleCreds.Expiration,
  82. }
  83. // Cap role credentials Expires to 1 hour so they can be refreshed more
  84. // often. Jitter will be applied credentials cache if being used.
  85. if anHour := sdk.NowTime().Add(1 * time.Hour); creds.Expires.After(anHour) {
  86. creds.Expires = anHour
  87. }
  88. return creds, nil
  89. }
  90. // HandleFailToRefresh will extend the credentials Expires time if it it is
  91. // expired. If the credentials will not expire within the minimum time, they
  92. // will be returned.
  93. //
  94. // If the credentials cannot expire, the original error will be returned.
  95. func (p *Provider) HandleFailToRefresh(ctx context.Context, prevCreds aws.Credentials, err error) (
  96. aws.Credentials, error,
  97. ) {
  98. if !prevCreds.CanExpire {
  99. return aws.Credentials{}, err
  100. }
  101. if prevCreds.Expires.After(sdk.NowTime().Add(5 * time.Minute)) {
  102. return prevCreds, nil
  103. }
  104. newCreds := prevCreds
  105. randFloat64, err := sdkrand.CryptoRandFloat64()
  106. if err != nil {
  107. return aws.Credentials{}, fmt.Errorf("failed to get random float, %w", err)
  108. }
  109. // Random distribution of [5,15) minutes.
  110. expireOffset := time.Duration(randFloat64*float64(10*time.Minute)) + 5*time.Minute
  111. newCreds.Expires = sdk.NowTime().Add(expireOffset)
  112. logger := middleware.GetLogger(ctx)
  113. logger.Logf(logging.Warn, "Attempting credential expiration extension due to a credential service availability issue. A refresh of these credentials will be attempted again in %v minutes.", math.Floor(expireOffset.Minutes()))
  114. return newCreds, nil
  115. }
  116. // AdjustExpiresBy will adds the passed in duration to the passed in
  117. // credential's Expires time, unless the time until Expires is less than 15
  118. // minutes. Returns the credentials, even if not updated.
  119. func (p *Provider) AdjustExpiresBy(creds aws.Credentials, dur time.Duration) (
  120. aws.Credentials, error,
  121. ) {
  122. if !creds.CanExpire {
  123. return creds, nil
  124. }
  125. if creds.Expires.Before(sdk.NowTime().Add(15 * time.Minute)) {
  126. return creds, nil
  127. }
  128. creds.Expires = creds.Expires.Add(dur)
  129. return creds, nil
  130. }
  131. // ec2RoleCredRespBody provides the shape for unmarshaling credential
  132. // request responses.
  133. type ec2RoleCredRespBody struct {
  134. // Success State
  135. Expiration time.Time
  136. AccessKeyID string
  137. SecretAccessKey string
  138. Token string
  139. // Error state
  140. Code string
  141. Message string
  142. }
  143. const iamSecurityCredsPath = "/iam/security-credentials/"
  144. // requestCredList requests a list of credentials from the EC2 service. If
  145. // there are no credentials, or there is an error making or receiving the
  146. // request
  147. func requestCredList(ctx context.Context, client GetMetadataAPIClient) ([]string, error) {
  148. resp, err := client.GetMetadata(ctx, &imds.GetMetadataInput{
  149. Path: iamSecurityCredsPath,
  150. })
  151. if err != nil {
  152. return nil, fmt.Errorf("no EC2 IMDS role found, %w", err)
  153. }
  154. defer resp.Content.Close()
  155. credsList := []string{}
  156. s := bufio.NewScanner(resp.Content)
  157. for s.Scan() {
  158. credsList = append(credsList, s.Text())
  159. }
  160. if err := s.Err(); err != nil {
  161. return nil, fmt.Errorf("failed to read EC2 IMDS role, %w", err)
  162. }
  163. return credsList, nil
  164. }
  165. // requestCred requests the credentials for a specific credentials from the EC2 service.
  166. //
  167. // If the credentials cannot be found, or there is an error reading the response
  168. // and error will be returned.
  169. func requestCred(ctx context.Context, client GetMetadataAPIClient, credsName string) (ec2RoleCredRespBody, error) {
  170. resp, err := client.GetMetadata(ctx, &imds.GetMetadataInput{
  171. Path: path.Join(iamSecurityCredsPath, credsName),
  172. })
  173. if err != nil {
  174. return ec2RoleCredRespBody{},
  175. fmt.Errorf("failed to get %s EC2 IMDS role credentials, %w",
  176. credsName, err)
  177. }
  178. defer resp.Content.Close()
  179. var respCreds ec2RoleCredRespBody
  180. if err := json.NewDecoder(resp.Content).Decode(&respCreds); err != nil {
  181. return ec2RoleCredRespBody{},
  182. fmt.Errorf("failed to decode %s EC2 IMDS role credentials, %w",
  183. credsName, err)
  184. }
  185. if !strings.EqualFold(respCreds.Code, "Success") {
  186. // If an error code was returned something failed requesting the role.
  187. return ec2RoleCredRespBody{},
  188. fmt.Errorf("failed to get %s EC2 IMDS role credentials, %w",
  189. credsName,
  190. &smithy.GenericAPIError{Code: respCreds.Code, Message: respCreds.Message})
  191. }
  192. return respCreds, nil
  193. }