← All changes
|
includes/sdk/s3/Aws/Credentials/EcsCredentialProvider.php
+129
-25
1.2.7
→
1.4.2
View file →
| @@ -1,15 +1,19 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | 3 | namespace Dudlewebs\WPMCS\s3\Aws\Credentials; |
| 4 | 4 | |
| 5 | +use Dudlewebs\WPMCS\s3\Aws\Arn\Arn; | |
| 5 | 6 | use Dudlewebs\WPMCS\s3\Aws\Exception\CredentialsException; |
| 7 | +use Dudlewebs\WPMCS\s3\GuzzleHttp\Exception\ConnectException; | |
| 8 | +use Dudlewebs\WPMCS\s3\GuzzleHttp\Exception\GuzzleException; | |
| 6 | 9 | use Dudlewebs\WPMCS\s3\GuzzleHttp\Psr7\Request; |
| 10 | +use Dudlewebs\WPMCS\s3\GuzzleHttp\Promise; | |
| 7 | 11 | use Dudlewebs\WPMCS\s3\GuzzleHttp\Promise\PromiseInterface; |
| 8 | 12 | use Dudlewebs\WPMCS\s3\Psr\Http\Message\ResponseInterface; |
| 9 | 13 | /** |
| 10 | - * Credential provider that fetches credentials with GET request. | |
| 11 | - * ECS environment variable is used in constructing request URI. | |
| 14 | + * Credential provider that fetches container credentials with GET request. | |
| 15 | + * container environment variables are used in constructing request URI. | |
| 12 | 16 | */ |
| 13 | 17 | class EcsCredentialProvider |
| 14 | 18 | { |
| 15 | 19 | const SERVER_URI = 'http://169.254.170.2'; |
| @@ -15,16 +19,27 @@ | ||
| 15 | 19 | const SERVER_URI = 'http://169.254.170.2'; |
| 16 | 20 | const ENV_URI = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"; |
| 17 | 21 | const ENV_FULL_URI = "AWS_CONTAINER_CREDENTIALS_FULL_URI"; |
| 18 | 22 | const ENV_AUTH_TOKEN = "AWS_CONTAINER_AUTHORIZATION_TOKEN"; |
| 23 | + const ENV_AUTH_TOKEN_FILE = "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE"; | |
| 19 | 24 | const ENV_TIMEOUT = 'AWS_METADATA_SERVICE_TIMEOUT'; |
| 25 | + const EKS_SERVER_HOST_IPV4 = '169.254.170.23'; | |
| 26 | + const EKS_SERVER_HOST_IPV6 = 'fd00:ec2::23'; | |
| 27 | + const ENV_RETRIES = 'AWS_METADATA_SERVICE_NUM_ATTEMPTS'; | |
| 28 | + const DEFAULT_ENV_TIMEOUT = 1.0; | |
| 29 | + const DEFAULT_ENV_RETRIES = 3; | |
| 20 | 30 | /** @var callable */ |
| 21 | 31 | private $client; |
| 22 | 32 | /** @var float|mixed */ |
| 23 | 33 | private $timeout; |
| 34 | + /** @var int */ | |
| 35 | + private $retries; | |
| 36 | + /** @var int */ | |
| 37 | + private $attempts; | |
| 24 | 38 | /** |
| 25 | 39 | * The constructor accepts following options: |
| 26 | 40 | * - timeout: (optional) Connection timeout, in seconds, default 1.0 |
| 41 | + * - retries: Optional number of retries to be attempted, default 3. | |
| 27 | 42 | * - client: An EcsClient to make request from |
| 28 | 43 | * |
| 29 | 44 | * @param array $config Configuration options |
| 30 | 45 | */ |
| @@ -29,38 +44,106 @@ | ||
| 29 | 44 | * @param array $config Configuration options |
| 30 | 45 | */ |
| 31 | 46 | public function __construct(array $config = []) |
| 32 | 47 | { |
| 33 | - $timeout = \getenv(self::ENV_TIMEOUT); | |
| 34 | - if (!$timeout) { | |
| 35 | - $timeout = isset($_SERVER[self::ENV_TIMEOUT]) ? $_SERVER[self::ENV_TIMEOUT] : (isset($config['timeout']) ? $config['timeout'] : 1.0); | |
| 36 | - } | |
| 37 | - $this->timeout = (float) $timeout; | |
| 38 | - $this->client = isset($config['client']) ? $config['client'] : \Dudlewebs\WPMCS\s3\Aws\default_http_handler(); | |
| 48 | + $this->timeout = (float) isset($config['timeout']) ? $config['timeout'] : (\getenv(self::ENV_TIMEOUT) ?: self::DEFAULT_ENV_TIMEOUT); | |
| 49 | + $this->retries = (int) isset($config['retries']) ? $config['retries'] : ((int) \getenv(self::ENV_RETRIES) ?: self::DEFAULT_ENV_RETRIES); | |
| 50 | + $this->client = $config['client'] ?? \Dudlewebs\WPMCS\s3\Aws\default_http_handler(); | |
| 39 | 51 | } |
| 40 | 52 | /** |
| 41 | - * Load ECS credentials | |
| 53 | + * Load container credentials. | |
| 42 | 54 | * |
| 43 | 55 | * @return PromiseInterface |
| 56 | + * @throws GuzzleException | |
| 44 | 57 | */ |
| 45 | 58 | public function __invoke() |
| 46 | 59 | { |
| 47 | - $client = $this->client; | |
| 48 | - $request = new Request('GET', self::getEcsUri()); | |
| 49 | - $headers = $this->setHeaderForAuthToken(); | |
| 50 | - return $client($request, ['timeout' => $this->timeout, 'proxy' => '', 'headers' => $headers])->then(function (ResponseInterface $response) { | |
| 51 | - $result = $this->decodeResult((string) $response->getBody()); | |
| 52 | - return new Credentials($result['AccessKeyId'], $result['SecretAccessKey'], $result['Token'], \strtotime($result['Expiration'])); | |
| 53 | - })->otherwise(function ($reason) { | |
| 54 | - $reason = \is_array($reason) ? $reason['exception'] : $reason; | |
| 55 | - $msg = $reason->getMessage(); | |
| 56 | - throw new CredentialsException("Error retrieving credential from ECS ({$msg})"); | |
| 57 | - }); | |
| 60 | + $this->attempts = 0; | |
| 61 | + $uri = $this->getEcsUri(); | |
| 62 | + if ($this->isCompatibleUri($uri)) { | |
| 63 | + return Promise\Coroutine::of(function () { | |
| 64 | + $client = $this->client; | |
| 65 | + $request = new Request('GET', $this->getEcsUri()); | |
| 66 | + $headers = $this->getHeadersForAuthToken(); | |
| 67 | + $credentials = null; | |
| 68 | + while ($credentials === null) { | |
| 69 | + $credentials = (yield $client($request, ['timeout' => $this->timeout, 'proxy' => '', 'headers' => $headers])->then(function (ResponseInterface $response) { | |
| 70 | + $result = $this->decodeResult((string) $response->getBody()); | |
| 71 | + if (!isset($result['AccountId']) && isset($result['RoleArn'])) { | |
| 72 | + try { | |
| 73 | + $parsedArn = new Arn($result['RoleArn']); | |
| 74 | + $result['AccountId'] = $parsedArn->getAccountId(); | |
| 75 | + } catch (\Exception $e) { | |
| 76 | + // AccountId will be null | |
| 77 | + } | |
| 78 | + } | |
| 79 | + return new Credentials($result['AccessKeyId'], $result['SecretAccessKey'], $result['Token'], \strtotime($result['Expiration']), $result['AccountId'] ?? null, CredentialSources::ECS); | |
| 80 | + })->otherwise(function ($reason) { | |
| 81 | + $reason = \is_array($reason) ? $reason['exception'] : $reason; | |
| 82 | + $isRetryable = $reason instanceof ConnectException; | |
| 83 | + if ($isRetryable && $this->attempts < $this->retries) { | |
| 84 | + \sleep((int) \pow(1.2, $this->attempts)); | |
| 85 | + } else { | |
| 86 | + $msg = $reason->getMessage(); | |
| 87 | + throw new CredentialsException(\sprintf('Error retrieving credentials from container metadata after attempt %d/%d (%s)', $this->attempts, $this->retries, $msg)); | |
| 88 | + } | |
| 89 | + })); | |
| 90 | + $this->attempts++; | |
| 91 | + } | |
| 92 | + (yield $credentials); | |
| 93 | + }); | |
| 94 | + } | |
| 95 | + throw new CredentialsException("Uri '{$uri}' contains an unsupported host."); | |
| 58 | 96 | } |
| 97 | + /** | |
| 98 | + * Returns the number of attempts that have been done. | |
| 99 | + * | |
| 100 | + * @return int | |
| 101 | + */ | |
| 102 | + public function getAttempts() : int | |
| 103 | + { | |
| 104 | + return $this->attempts; | |
| 105 | + } | |
| 106 | + /** | |
| 107 | + * Retrieves authorization token. | |
| 108 | + * | |
| 109 | + * @return array|false|string | |
| 110 | + */ | |
| 59 | 111 | private function getEcsAuthToken() |
| 60 | 112 | { |
| 113 | + if (!empty($path = \getenv(self::ENV_AUTH_TOKEN_FILE))) { | |
| 114 | + $token = @\file_get_contents($path); | |
| 115 | + if (\false === $token) { | |
| 116 | + \clearstatcache(\true, \dirname($path) . \DIRECTORY_SEPARATOR . @\readlink($path)); | |
| 117 | + \clearstatcache(\true, \dirname($path) . \DIRECTORY_SEPARATOR . \dirname(@\readlink($path))); | |
| 118 | + \clearstatcache(\true, $path); | |
| 119 | + } | |
| 120 | + if (!\is_readable($path)) { | |
| 121 | + throw new CredentialsException("Failed to read authorization token from '{$path}': no such file or directory."); | |
| 122 | + } | |
| 123 | + $token = @\file_get_contents($path); | |
| 124 | + if (empty($token)) { | |
| 125 | + throw new CredentialsException("Invalid authorization token read from `{$path}`. Token file is empty!"); | |
| 126 | + } | |
| 127 | + return $token; | |
| 128 | + } | |
| 61 | 129 | return \getenv(self::ENV_AUTH_TOKEN); |
| 62 | 130 | } |
| 131 | + /** | |
| 132 | + * Provides headers for credential metadata request. | |
| 133 | + * | |
| 134 | + * @return array|array[]|string[] | |
| 135 | + */ | |
| 136 | + private function getHeadersForAuthToken() | |
| 137 | + { | |
| 138 | + $authToken = self::getEcsAuthToken(); | |
| 139 | + $headers = []; | |
| 140 | + if (!empty($authToken)) { | |
| 141 | + $headers = ['Authorization' => $authToken]; | |
| 142 | + } | |
| 143 | + return $headers; | |
| 144 | + } | |
| 145 | + /** @deprecated */ | |
| 63 | 146 | public function setHeaderForAuthToken() |
| 64 | 147 | { |
| 65 | 148 | $authToken = self::getEcsAuthToken(); |
| 66 | 149 | $headers = []; |
| @@ -69,22 +152,22 @@ | ||
| 69 | 152 | } |
| 70 | 153 | return $headers; |
| 71 | 154 | } |
| 72 | 155 | /** |
| 73 | - * Fetch credential URI from ECS environment variable | |
| 156 | + * Fetch container metadata URI from container environment variable. | |
| 74 | 157 | * |
| 75 | - * @return string Returns ECS URI | |
| 158 | + * @return string Returns container metadata URI | |
| 76 | 159 | */ |
| 77 | 160 | private function getEcsUri() |
| 78 | 161 | { |
| 79 | 162 | $credsUri = \getenv(self::ENV_URI); |
| 80 | 163 | if ($credsUri === \false) { |
| 81 | - $credsUri = isset($_SERVER[self::ENV_URI]) ? $_SERVER[self::ENV_URI] : ''; | |
| 164 | + $credsUri = $_SERVER[self::ENV_URI] ?? ''; | |
| 82 | 165 | } |
| 83 | 166 | if (empty($credsUri)) { |
| 84 | 167 | $credFullUri = \getenv(self::ENV_FULL_URI); |
| 85 | 168 | if ($credFullUri === \false) { |
| 86 | - $credFullUri = isset($_SERVER[self::ENV_FULL_URI]) ? $_SERVER[self::ENV_FULL_URI] : ''; | |
| 169 | + $credFullUri = $_SERVER[self::ENV_FULL_URI] ?? ''; | |
| 87 | 170 | } |
| 88 | 171 | if (!empty($credFullUri)) { |
| 89 | 172 | return $credFullUri; |
| 90 | 173 | } |
| @@ -94,9 +177,30 @@ | ||
| 94 | 177 | private function decodeResult($response) |
| 95 | 178 | { |
| 96 | 179 | $result = \json_decode($response, \true); |
| 97 | 180 | if (!isset($result['AccessKeyId'])) { |
| 98 | - throw new CredentialsException('Unexpected ECS credential value'); | |
| 181 | + throw new CredentialsException('Unexpected container metadata credentials value'); | |
| 99 | 182 | } |
| 100 | 183 | return $result; |
| 184 | + } | |
| 185 | + /** | |
| 186 | + * Determines whether or not a given request URI is a valid | |
| 187 | + * container credential request URI. | |
| 188 | + * | |
| 189 | + * @param $uri | |
| 190 | + * | |
| 191 | + * @return bool | |
| 192 | + */ | |
| 193 | + private function isCompatibleUri($uri) | |
| 194 | + { | |
| 195 | + $parsed = \parse_url($uri); | |
| 196 | + if ($parsed['scheme'] !== 'https') { | |
| 197 | + $host = \trim($parsed['host'], '[]'); | |
| 198 | + $ecsHost = \parse_url(self::SERVER_URI)['host']; | |
| 199 | + $eksHost = self::EKS_SERVER_HOST_IPV4; | |
| 200 | + if ($host !== $ecsHost && $host !== $eksHost && $host !== self::EKS_SERVER_HOST_IPV6 && !CredentialsUtils::isLoopBackAddress(\gethostbyname($host))) { | |
| 201 | + return \false; | |
| 202 | + } | |
| 203 | + } | |
| 204 | + return \true; | |
| 101 | 205 | } |
| 102 | 206 | } |