timeout = (float) \getenv(self::ENV_TIMEOUT) ?: $config['timeout'] ?? self::DEFAULT_TIMEOUT; $this->profile = $config['profile'] ?? null; $this->retries = (int) \getenv(self::ENV_RETRIES) ?: $config['retries'] ?? self::DEFAULT_RETRIES; $this->client = $config['client'] ?? \DeliciousBrains\WP_Offload_Media\Aws3\Aws\default_http_handler(); $this->ec2MetadataV1Disabled = $config[self::CFG_EC2_METADATA_V1_DISABLED] ?? null; $this->endpoint = $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT] ?? null; if (!empty($this->endpoint) && !$this->isValidEndpoint($this->endpoint)) { throw new \InvalidArgumentException('The provided URI "' . $this->endpoint . '" is invalid, or contains an unsupported host'); } $this->endpointMode = $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE] ?? null; $this->config = $config; } /** * Loads instance profile credentials. * * @return PromiseInterface */ public function __invoke($previousCredentials = null) { $this->attempts = 0; return Promise\Coroutine::of(function () use($previousCredentials) { // Retrieve token or switch out of secure mode $token = null; while ($this->secureMode && \is_null($token)) { try { $token = (yield $this->request(self::TOKEN_PATH, 'PUT', ['x-aws-ec2-metadata-token-ttl-seconds' => self::DEFAULT_TOKEN_TTL_SECONDS])); } catch (TransferException $e) { if ($this->getExceptionStatusCode($e) === 500 && $previousCredentials instanceof Credentials) { goto generateCredentials; } elseif ($this->shouldFallbackToIMDSv1() && (!\method_exists($e, 'getResponse') || empty($e->getResponse()) || !\in_array($e->getResponse()->getStatusCode(), [400, 500, 502, 503, 504]))) { $this->secureMode = \false; } else { $this->handleRetryableException($e, [], $this->createErrorMessage('Error retrieving metadata token')); } } $this->attempts++; } // Set token header only for secure mode $headers = []; if ($this->secureMode) { $headers = ['x-aws-ec2-metadata-token' => $token]; } // Retrieve profile while (!$this->profile) { try { $this->profile = (yield $this->request(self::CRED_PATH, 'GET', $headers)); } catch (TransferException $e) { // 401 indicates insecure flow not supported, switch to // attempting secure mode for subsequent calls if (!empty($this->getExceptionStatusCode($e)) && $this->getExceptionStatusCode($e) === 401) { $this->secureMode = \true; } $this->handleRetryableException($e, ['blacklist' => [401, 403]], $this->createErrorMessage($e->getMessage())); } $this->attempts++; } // Retrieve credentials $result = null; while ($result == null) { try { $json = (yield $this->request(self::CRED_PATH . $this->profile, 'GET', $headers)); $result = $this->decodeResult($json); } catch (InvalidJsonException $e) { $this->handleRetryableException($e, ['blacklist' => [401, 403]], $this->createErrorMessage('Invalid JSON response, retries exhausted')); } catch (TransferException $e) { // 401 indicates insecure flow not supported, switch to // attempting secure mode for subsequent calls if (($this->getExceptionStatusCode($e) === 500 || \strpos($e->getMessage(), "cURL error 28") !== \false) && $previousCredentials instanceof Credentials) { goto generateCredentials; } elseif (!empty($this->getExceptionStatusCode($e)) && $this->getExceptionStatusCode($e) === 401) { $this->secureMode = \true; } $this->handleRetryableException($e, ['blacklist' => [401, 403]], $this->createErrorMessage($e->getMessage())); } $this->attempts++; } generateCredentials: if (!isset($result)) { $credentials = $previousCredentials; } else { $credentials = new Credentials($result['AccessKeyId'], $result['SecretAccessKey'], $result['Token'], \strtotime($result['Expiration']), $result['AccountId'] ?? null); } if ($credentials->isExpired()) { $credentials->extendExpiration(); } (yield $credentials); }); } /** * @param string $url * @param string $method * @param array $headers * @return PromiseInterface Returns a promise that is fulfilled with the * body of the response as a string. */ private function request($url, $method = 'GET', $headers = []) { $disabled = \getenv(self::ENV_DISABLE) ?: \false; if (\strcasecmp($disabled, 'true') === 0) { throw new CredentialsException($this->createErrorMessage('EC2 metadata service access disabled')); } $fn = $this->client; $request = new Request($method, $this->resolveEndpoint() . $url); $userAgent = 'aws-sdk-php/' . Sdk::VERSION; if (\defined('DeliciousBrains\\WP_Offload_Media\\Aws3\\HHVM_VERSION')) { $userAgent .= ' HHVM/' . HHVM_VERSION; } $userAgent .= ' ' . \DeliciousBrains\WP_Offload_Media\Aws3\Aws\default_user_agent(); $request = $request->withHeader('User-Agent', $userAgent); foreach ($headers as $key => $value) { $request = $request->withHeader($key, $value); } return $fn($request, ['timeout' => $this->timeout])->then(function (ResponseInterface $response) { return (string) $response->getBody(); })->otherwise(function (array $reason) { $reason = $reason['exception']; if ($reason instanceof TransferException) { throw $reason; } $msg = $reason->getMessage(); throw new CredentialsException($this->createErrorMessage($msg)); }); } private function handleRetryableException(\Exception $e, $retryOptions, $message) { $isRetryable = \true; if (!empty($status = $this->getExceptionStatusCode($e)) && isset($retryOptions['blacklist']) && \in_array($status, $retryOptions['blacklist'])) { $isRetryable = \false; } if ($isRetryable && $this->attempts < $this->retries) { \sleep((int) \pow(1.2, $this->attempts)); } else { throw new CredentialsException($message); } } private function getExceptionStatusCode(\Exception $e) { if (\method_exists($e, 'getResponse') && !empty($e->getResponse())) { return $e->getResponse()->getStatusCode(); } return null; } private function createErrorMessage($previous) { return "Error retrieving credentials from the instance profile " . "metadata service. ({$previous})"; } private function decodeResult($response) { $result = \json_decode($response, \true); if (\json_last_error() > 0) { throw new InvalidJsonException(); } if ($result['Code'] !== 'Success') { throw new CredentialsException('Unexpected instance profile ' . 'response code: ' . $result['Code']); } return $result; } /** * This functions checks for whether we should fall back to IMDSv1 or not. * If $ec2MetadataV1Disabled is null then we will try to resolve this value from * the following sources: * - From environment: "AWS_EC2_METADATA_V1_DISABLED". * - From config file: aws_ec2_metadata_v1_disabled * - Defaulted to false * * @return bool */ private function shouldFallbackToIMDSv1() : bool { $isImdsV1Disabled = \DeliciousBrains\WP_Offload_Media\Aws3\Aws\boolean_value($this->ec2MetadataV1Disabled) ?? \DeliciousBrains\WP_Offload_Media\Aws3\Aws\boolean_value(ConfigurationResolver::resolve(self::CFG_EC2_METADATA_V1_DISABLED, self::DEFAULT_AWS_EC2_METADATA_V1_DISABLED, 'bool', $this->config)) ?? self::DEFAULT_AWS_EC2_METADATA_V1_DISABLED; return !$isImdsV1Disabled; } /** * Resolves the metadata service endpoint. If the endpoint is not provided * or configured then, the default endpoint, based on the endpoint mode resolved, * will be used. * Example: if endpoint_mode is resolved to be IPv4 and the endpoint is not provided * then, the endpoint to be used will be http://169.254.169.254. * * @return string */ private function resolveEndpoint() : string { $endpoint = $this->endpoint; if (\is_null($endpoint)) { $endpoint = ConfigurationResolver::resolve(self::CFG_EC2_METADATA_SERVICE_ENDPOINT, $this->getDefaultEndpoint(), 'string', $this->config); } if (!$this->isValidEndpoint($endpoint)) { throw new CredentialsException('The provided URI "' . $endpoint . '" is invalid, or contains an unsupported host'); } if (\substr($endpoint, \strlen($endpoint) - 1) !== '/') { $endpoint = $endpoint . '/'; } return $endpoint . 'latest/'; } /** * Resolves the default metadata service endpoint. * If endpoint_mode is resolved as IPv4 then: * - endpoint = http://169.254.169.254 * If endpoint_mode is resolved as IPv6 then: * - endpoint = http://[fd00:ec2::254] * * @return string */ private function getDefaultEndpoint() : string { $endpointMode = $this->resolveEndpointMode(); switch ($endpointMode) { case self::ENDPOINT_MODE_IPv4: return self::DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT; case self::ENDPOINT_MODE_IPv6: return self::DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT; } throw new CredentialsException("Invalid endpoint mode '{$endpointMode}' resolved"); } /** * Resolves the endpoint mode to be considered when resolving the default * metadata service endpoint. * * @return string */ private function resolveEndpointMode() : string { $endpointMode = $this->endpointMode; if (\is_null($endpointMode)) { $endpointMode = ConfigurationResolver::resolve(self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE, self::ENDPOINT_MODE_IPv4, 'string', $this->config); } return $endpointMode; } /** * This method checks for whether a provide URI is valid. * @param string $uri this parameter is the uri to do the validation against to. * * @return string|null */ private function isValidEndpoint($uri) : bool { // We make sure first the provided uri is a valid URL $isValidURL = \filter_var($uri, \FILTER_VALIDATE_URL) !== \false; if (!$isValidURL) { return \false; } // We make sure that if is a no secure host then it must be a loop back address. $parsedUri = \parse_url($uri); if ($parsedUri['scheme'] !== 'https') { $host = \trim($parsedUri['host'], '[]'); return CredentialsUtils::isLoopBackAddress(\gethostbyname($host)) || \in_array($uri, [self::DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT, self::DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT]); } return \true; } }