Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
namespace Aws\Credentials;
3
 
1441 ariadna 4
use Aws\Configuration\ConfigurationResolver;
1 efrain 5
use Aws\Exception\CredentialsException;
6
use Aws\Exception\InvalidJsonException;
7
use Aws\Sdk;
8
use GuzzleHttp\Exception\TransferException;
9
use GuzzleHttp\Promise;
10
use GuzzleHttp\Psr7\Request;
11
use GuzzleHttp\Promise\PromiseInterface;
12
use Psr\Http\Message\ResponseInterface;
13
 
14
/**
15
 * Credential provider that provides credentials from the EC2 metadata service.
16
 */
17
class InstanceProfileProvider
18
{
19
    const CRED_PATH = 'meta-data/iam/security-credentials/';
20
    const TOKEN_PATH = 'api/token';
21
    const ENV_DISABLE = 'AWS_EC2_METADATA_DISABLED';
22
    const ENV_TIMEOUT = 'AWS_METADATA_SERVICE_TIMEOUT';
23
    const ENV_RETRIES = 'AWS_METADATA_SERVICE_NUM_ATTEMPTS';
1441 ariadna 24
    const CFG_EC2_METADATA_V1_DISABLED = 'ec2_metadata_v1_disabled';
25
    const CFG_EC2_METADATA_SERVICE_ENDPOINT = 'ec2_metadata_service_endpoint';
26
    const CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE = 'ec2_metadata_service_endpoint_mode';
27
    const DEFAULT_TIMEOUT = 1.0;
28
    const DEFAULT_RETRIES = 3;
29
    const DEFAULT_TOKEN_TTL_SECONDS = 21600;
30
    const DEFAULT_AWS_EC2_METADATA_V1_DISABLED = false;
31
    const ENDPOINT_MODE_IPv4 = 'IPv4';
32
    const ENDPOINT_MODE_IPv6 = 'IPv6';
33
    const DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT = 'http://169.254.169.254';
34
    const DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT = 'http://[fd00:ec2::254]';
1 efrain 35
 
36
    /** @var string */
37
    private $profile;
38
 
39
    /** @var callable */
40
    private $client;
41
 
42
    /** @var int */
43
    private $retries;
44
 
45
    /** @var int */
46
    private $attempts;
47
 
48
    /** @var float|mixed */
49
    private $timeout;
50
 
51
    /** @var bool */
52
    private $secureMode = true;
53
 
1441 ariadna 54
    /** @var bool|null */
55
    private $ec2MetadataV1Disabled;
56
 
57
    /** @var string */
58
    private $endpoint;
59
 
60
    /** @var string */
61
    private $endpointMode;
62
 
63
    /** @var array */
64
    private $config;
65
 
1 efrain 66
    /**
67
     * The constructor accepts the following options:
68
     *
69
     * - timeout: Connection timeout, in seconds.
70
     * - profile: Optional EC2 profile name, if known.
71
     * - retries: Optional number of retries to be attempted.
1441 ariadna 72
     * - ec2_metadata_v1_disabled: Optional for disabling the fallback to IMDSv1.
73
     * - endpoint: Optional for overriding the default endpoint to be used for fetching credentials.
74
     *   The value must contain a valid URI scheme. If the URI scheme is not https, it must
75
     *   resolve to a loopback address.
76
     * - endpoint_mode: Optional for overriding the default endpoint mode (IPv4|IPv6) to be used for
77
     *   resolving the default endpoint.
78
     * - use_aws_shared_config_files: Decides whether the shared config file should be considered when
79
     *   using the ConfigurationResolver::resolve method.
1 efrain 80
     *
81
     * @param array $config Configuration options.
82
     */
83
    public function __construct(array $config = [])
84
    {
1441 ariadna 85
        $this->timeout = (float) getenv(self::ENV_TIMEOUT) ?: ($config['timeout'] ?? self::DEFAULT_TIMEOUT);
86
        $this->profile = $config['profile'] ?? null;
87
        $this->retries = (int) getenv(self::ENV_RETRIES) ?: ($config['retries'] ?? self::DEFAULT_RETRIES);
88
        $this->client = $config['client'] ?? \Aws\default_http_handler();
89
        $this->ec2MetadataV1Disabled = $config[self::CFG_EC2_METADATA_V1_DISABLED] ?? null;
90
        $this->endpoint = $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT] ?? null;
91
        if (!empty($this->endpoint) && !$this->isValidEndpoint($this->endpoint)) {
92
            throw new \InvalidArgumentException('The provided URI "' . $this->endpoint . '" is invalid, or contains an unsupported host');
93
        }
94
 
95
        $this->endpointMode = $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE] ?? null;
96
        $this->config = $config;
1 efrain 97
    }
98
 
99
    /**
100
     * Loads instance profile credentials.
101
     *
102
     * @return PromiseInterface
103
     */
104
    public function __invoke($previousCredentials = null)
105
    {
106
        $this->attempts = 0;
107
        return Promise\Coroutine::of(function () use ($previousCredentials) {
108
 
109
            // Retrieve token or switch out of secure mode
110
            $token = null;
111
            while ($this->secureMode && is_null($token)) {
112
                try {
113
                    $token = (yield $this->request(
114
                        self::TOKEN_PATH,
115
                        'PUT',
116
                        [
1441 ariadna 117
                            'x-aws-ec2-metadata-token-ttl-seconds' => self::DEFAULT_TOKEN_TTL_SECONDS
1 efrain 118
                        ]
119
                    ));
120
                } catch (TransferException $e) {
121
                    if ($this->getExceptionStatusCode($e) === 500
122
                        && $previousCredentials instanceof Credentials
123
                    ) {
124
                        goto generateCredentials;
1441 ariadna 125
                    } elseif ($this->shouldFallbackToIMDSv1()
126
                        && (!method_exists($e, 'getResponse')
1 efrain 127
                        || empty($e->getResponse())
128
                        || !in_array(
129
                            $e->getResponse()->getStatusCode(),
130
                            [400, 500, 502, 503, 504]
1441 ariadna 131
                        ))
1 efrain 132
                    ) {
133
                        $this->secureMode = false;
134
                    } else {
135
                        $this->handleRetryableException(
136
                            $e,
137
                            [],
138
                            $this->createErrorMessage(
139
                                'Error retrieving metadata token'
140
                            )
141
                        );
142
                    }
143
                }
144
                $this->attempts++;
145
            }
146
 
147
            // Set token header only for secure mode
148
            $headers = [];
149
            if ($this->secureMode) {
150
                $headers = [
151
                    'x-aws-ec2-metadata-token' => $token
152
                ];
153
            }
154
 
155
            // Retrieve profile
156
            while (!$this->profile) {
157
                try {
158
                    $this->profile = (yield $this->request(
159
                        self::CRED_PATH,
160
                        'GET',
161
                        $headers
162
                    ));
163
                } catch (TransferException $e) {
164
                    // 401 indicates insecure flow not supported, switch to
165
                    // attempting secure mode for subsequent calls
166
                    if (!empty($this->getExceptionStatusCode($e))
167
                        && $this->getExceptionStatusCode($e) === 401
168
                    ) {
169
                        $this->secureMode = true;
170
                    }
171
                    $this->handleRetryableException(
172
                        $e,
173
                        [ 'blacklist' => [401, 403] ],
174
                        $this->createErrorMessage($e->getMessage())
175
                    );
176
                }
177
 
178
                $this->attempts++;
179
            }
180
 
181
            // Retrieve credentials
182
            $result = null;
183
            while ($result == null) {
184
                try {
185
                    $json = (yield $this->request(
186
                        self::CRED_PATH . $this->profile,
187
                        'GET',
188
                        $headers
189
                    ));
190
                    $result = $this->decodeResult($json);
191
                } catch (InvalidJsonException $e) {
192
                    $this->handleRetryableException(
193
                        $e,
194
                        [ 'blacklist' => [401, 403] ],
195
                        $this->createErrorMessage(
196
                            'Invalid JSON response, retries exhausted'
197
                        )
198
                    );
199
                } catch (TransferException $e) {
200
                    // 401 indicates insecure flow not supported, switch to
201
                    // attempting secure mode for subsequent calls
202
                    if (($this->getExceptionStatusCode($e) === 500
203
                            || strpos($e->getMessage(), "cURL error 28") !== false)
204
                        && $previousCredentials instanceof Credentials
205
                    ) {
206
                        goto generateCredentials;
1441 ariadna 207
                    } elseif (!empty($this->getExceptionStatusCode($e))
1 efrain 208
                        && $this->getExceptionStatusCode($e) === 401
209
                    ) {
210
                        $this->secureMode = true;
211
                    }
212
                    $this->handleRetryableException(
213
                        $e,
214
                        [ 'blacklist' => [401, 403] ],
215
                        $this->createErrorMessage($e->getMessage())
216
                    );
217
                }
218
                $this->attempts++;
219
            }
220
            generateCredentials:
221
 
222
            if (!isset($result)) {
223
                $credentials = $previousCredentials;
224
            } else {
225
                $credentials = new Credentials(
226
                    $result['AccessKeyId'],
227
                    $result['SecretAccessKey'],
228
                    $result['Token'],
1441 ariadna 229
                    strtotime($result['Expiration']),
230
                    $result['AccountId'] ?? null,
231
                    CredentialSources::IMDS
1 efrain 232
                );
233
            }
234
 
235
            if ($credentials->isExpired()) {
236
                $credentials->extendExpiration();
237
            }
238
 
239
            yield $credentials;
240
        });
241
    }
242
 
243
    /**
244
     * @param string $url
245
     * @param string $method
246
     * @param array $headers
247
     * @return PromiseInterface Returns a promise that is fulfilled with the
248
     *                          body of the response as a string.
249
     */
250
    private function request($url, $method = 'GET', $headers = [])
251
    {
252
        $disabled = getenv(self::ENV_DISABLE) ?: false;
253
        if (strcasecmp($disabled, 'true') === 0) {
254
            throw new CredentialsException(
255
                $this->createErrorMessage('EC2 metadata service access disabled')
256
            );
257
        }
258
 
259
        $fn = $this->client;
1441 ariadna 260
        $request = new Request($method, $this->resolveEndpoint() . $url);
1 efrain 261
        $userAgent = 'aws-sdk-php/' . Sdk::VERSION;
262
        if (defined('HHVM_VERSION')) {
263
            $userAgent .= ' HHVM/' . HHVM_VERSION;
264
        }
265
        $userAgent .= ' ' . \Aws\default_user_agent();
266
        $request = $request->withHeader('User-Agent', $userAgent);
267
        foreach ($headers as $key => $value) {
268
            $request = $request->withHeader($key, $value);
269
        }
270
 
271
        return $fn($request, ['timeout' => $this->timeout])
272
            ->then(function (ResponseInterface $response) {
273
                return (string) $response->getBody();
274
            })->otherwise(function (array $reason) {
275
                $reason = $reason['exception'];
276
                if ($reason instanceof TransferException) {
277
                    throw $reason;
278
                }
279
                $msg = $reason->getMessage();
280
                throw new CredentialsException(
281
                    $this->createErrorMessage($msg)
282
                );
283
            });
284
    }
285
 
286
    private function handleRetryableException(
287
        \Exception $e,
288
        $retryOptions,
289
        $message
290
    ) {
291
        $isRetryable = true;
292
        if (!empty($status = $this->getExceptionStatusCode($e))
293
            && isset($retryOptions['blacklist'])
294
            && in_array($status, $retryOptions['blacklist'])
295
        ) {
296
            $isRetryable = false;
297
        }
298
        if ($isRetryable && $this->attempts < $this->retries) {
299
            sleep((int) pow(1.2, $this->attempts));
300
        } else {
301
            throw new CredentialsException($message);
302
        }
303
    }
304
 
305
    private function getExceptionStatusCode(\Exception $e)
306
    {
307
        if (method_exists($e, 'getResponse')
308
            && !empty($e->getResponse())
309
        ) {
310
            return $e->getResponse()->getStatusCode();
311
        }
312
        return null;
313
    }
314
 
315
    private function createErrorMessage($previous)
316
    {
317
        return "Error retrieving credentials from the instance profile "
318
            . "metadata service. ({$previous})";
319
    }
320
 
321
    private function decodeResult($response)
322
    {
323
        $result = json_decode($response, true);
324
 
325
        if (json_last_error() > 0) {
326
            throw new InvalidJsonException();
327
        }
328
 
329
        if ($result['Code'] !== 'Success') {
330
            throw new CredentialsException('Unexpected instance profile '
331
                .  'response code: ' . $result['Code']);
332
        }
333
 
334
        return $result;
335
    }
1441 ariadna 336
 
337
    /**
338
     * This functions checks for whether we should fall back to IMDSv1 or not.
339
     * If $ec2MetadataV1Disabled is null then we will try to resolve this value from
340
     * the following sources:
341
     * - From environment: "AWS_EC2_METADATA_V1_DISABLED".
342
     * - From config file: aws_ec2_metadata_v1_disabled
343
     * - Defaulted to false
344
     *
345
     * @return bool
346
     */
347
    private function shouldFallbackToIMDSv1(): bool
348
    {
349
        $isImdsV1Disabled = \Aws\boolean_value($this->ec2MetadataV1Disabled)
350
            ?? \Aws\boolean_value(
351
                ConfigurationResolver::resolve(
352
                    self::CFG_EC2_METADATA_V1_DISABLED,
353
                    self::DEFAULT_AWS_EC2_METADATA_V1_DISABLED,
354
                    'bool',
355
                    $this->config
356
                )
357
            )
358
            ?? self::DEFAULT_AWS_EC2_METADATA_V1_DISABLED;
359
 
360
        return !$isImdsV1Disabled;
361
    }
362
 
363
    /**
364
     * Resolves the metadata service endpoint. If the endpoint is not provided
365
     * or configured then, the default endpoint, based on the endpoint mode resolved,
366
     * will be used.
367
     * Example: if endpoint_mode is resolved to be IPv4 and the endpoint is not provided
368
     * then, the endpoint to be used will be http://169.254.169.254.
369
     *
370
     * @return string
371
     */
372
    private function resolveEndpoint(): string
373
    {
374
        $endpoint = $this->endpoint;
375
        if (is_null($endpoint)) {
376
            $endpoint = ConfigurationResolver::resolve(
377
                self::CFG_EC2_METADATA_SERVICE_ENDPOINT,
378
                $this->getDefaultEndpoint(),
379
                'string',
380
                $this->config
381
            );
382
        }
383
 
384
        if (!$this->isValidEndpoint($endpoint)) {
385
            throw new CredentialsException('The provided URI "' . $endpoint . '" is invalid, or contains an unsupported host');
386
        }
387
 
388
        if (substr($endpoint, strlen($endpoint) - 1) !== '/') {
389
            $endpoint = $endpoint . '/';
390
        }
391
 
392
        return $endpoint . 'latest/';
393
    }
394
 
395
    /**
396
     * Resolves the default metadata service endpoint.
397
     * If endpoint_mode is resolved as IPv4 then:
398
     * - endpoint = http://169.254.169.254
399
     * If endpoint_mode is resolved as IPv6 then:
400
     * - endpoint = http://[fd00:ec2::254]
401
     *
402
     * @return string
403
     */
404
    private function getDefaultEndpoint(): string
405
    {
406
        $endpointMode = $this->resolveEndpointMode();
407
        switch ($endpointMode) {
408
            case self::ENDPOINT_MODE_IPv4:
409
                return self::DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT;
410
            case self::ENDPOINT_MODE_IPv6:
411
                return self::DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT;
412
        }
413
 
414
        throw new CredentialsException("Invalid endpoint mode '$endpointMode' resolved");
415
    }
416
 
417
    /**
418
     * Resolves the endpoint mode to be considered when resolving the default
419
     * metadata service endpoint.
420
     *
421
     * @return string
422
     */
423
    private function resolveEndpointMode(): string
424
    {
425
        $endpointMode = $this->endpointMode;
426
        if (is_null($endpointMode)) {
427
            $endpointMode = ConfigurationResolver::resolve(
428
                self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE,
429
                    self::ENDPOINT_MODE_IPv4,
430
                'string',
431
                $this->config
432
            );
433
        }
434
 
435
        return $endpointMode;
436
    }
437
 
438
    /**
439
     * This method checks for whether a provide URI is valid.
440
     * @param string $uri this parameter is the uri to do the validation against to.
441
     *
442
     * @return string|null
443
     */
444
    private function isValidEndpoint(
445
        $uri
446
    ): bool
447
    {
448
        // We make sure first the provided uri is a valid URL
449
        $isValidURL = filter_var($uri, FILTER_VALIDATE_URL) !== false;
450
        if (!$isValidURL) {
451
            return false;
452
        }
453
 
454
        // We make sure that if is a no secure host then it must be a loop back address.
455
        $parsedUri = parse_url($uri);
456
        if ($parsedUri['scheme'] !== 'https') {
457
            $host = trim($parsedUri['host'], '[]');
458
 
459
            return CredentialsUtils::isLoopBackAddress(gethostbyname($host))
460
                || in_array(
461
                    $uri,
462
                    [self::DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT, self::DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT]
463
                );
464
        }
465
 
466
        return true;
467
    }
1 efrain 468
}