InstanceProfileProvider.php 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. <?php
  2. namespace Aws\Credentials;
  3. use Aws\Exception\CredentialsException;
  4. use Aws\Exception\InvalidJsonException;
  5. use Aws\Sdk;
  6. use GuzzleHttp\Exception\TransferException;
  7. use GuzzleHttp\Promise;
  8. use GuzzleHttp\Exception\RequestException;
  9. use GuzzleHttp\Psr7\Request;
  10. use GuzzleHttp\Promise\PromiseInterface;
  11. use Psr\Http\Message\ResponseInterface;
  12. /**
  13. * Credential provider that provides credentials from the EC2 metadata service.
  14. */
  15. class InstanceProfileProvider
  16. {
  17. const SERVER_URI = 'http://169.254.169.254/latest/';
  18. const CRED_PATH = 'meta-data/iam/security-credentials/';
  19. const TOKEN_PATH = 'api/token';
  20. const ENV_DISABLE = 'AWS_EC2_METADATA_DISABLED';
  21. const ENV_TIMEOUT = 'AWS_METADATA_SERVICE_TIMEOUT';
  22. const ENV_RETRIES = 'AWS_METADATA_SERVICE_NUM_ATTEMPTS';
  23. /** @var string */
  24. private $profile;
  25. /** @var callable */
  26. private $client;
  27. /** @var int */
  28. private $retries;
  29. /** @var int */
  30. private $attempts;
  31. /** @var float|mixed */
  32. private $timeout;
  33. /** @var bool */
  34. private $secureMode = true;
  35. /**
  36. * The constructor accepts the following options:
  37. *
  38. * - timeout: Connection timeout, in seconds.
  39. * - profile: Optional EC2 profile name, if known.
  40. * - retries: Optional number of retries to be attempted.
  41. *
  42. * @param array $config Configuration options.
  43. */
  44. public function __construct(array $config = [])
  45. {
  46. $this->timeout = (float) getenv(self::ENV_TIMEOUT) ?: (isset($config['timeout']) ? $config['timeout'] : 1.0);
  47. $this->profile = isset($config['profile']) ? $config['profile'] : null;
  48. $this->retries = (int) getenv(self::ENV_RETRIES) ?: (isset($config['retries']) ? $config['retries'] : 3);
  49. $this->attempts = 0;
  50. $this->client = isset($config['client'])
  51. ? $config['client'] // internal use only
  52. : \Aws\default_http_handler();
  53. }
  54. /**
  55. * Loads instance profile credentials.
  56. *
  57. * @return PromiseInterface
  58. */
  59. public function __invoke()
  60. {
  61. return Promise\coroutine(function () {
  62. // Retrieve token or switch out of secure mode
  63. $token = null;
  64. while ($this->secureMode && is_null($token)) {
  65. try {
  66. $token = (yield $this->request(
  67. self::TOKEN_PATH,
  68. 'PUT',
  69. [
  70. 'x-aws-ec2-metadata-token-ttl-seconds' => 21600
  71. ]
  72. ));
  73. } catch (TransferException $e) {
  74. if (!method_exists($e, 'getResponse')
  75. || empty($e->getResponse())
  76. || !in_array(
  77. $e->getResponse()->getStatusCode(),
  78. [400, 500, 502, 503, 504]
  79. )
  80. ) {
  81. $this->secureMode = false;
  82. } else {
  83. $this->handleRetryableException(
  84. $e,
  85. [],
  86. $this->createErrorMessage(
  87. 'Error retrieving metadata token'
  88. )
  89. );
  90. }
  91. }
  92. $this->attempts++;
  93. }
  94. // Set token header only for secure mode
  95. $headers = [];
  96. if ($this->secureMode) {
  97. $headers = [
  98. 'x-aws-ec2-metadata-token' => $token
  99. ];
  100. }
  101. // Retrieve profile
  102. while (!$this->profile) {
  103. try {
  104. $this->profile = (yield $this->request(
  105. self::CRED_PATH,
  106. 'GET',
  107. $headers
  108. ));
  109. } catch (TransferException $e) {
  110. // 401 indicates insecure flow not supported, switch to
  111. // attempting secure mode for subsequent calls
  112. if (!empty($this->getExceptionStatusCode($e))
  113. && $this->getExceptionStatusCode($e) === 401
  114. ) {
  115. $this->secureMode = true;
  116. }
  117. $this->handleRetryableException(
  118. $e,
  119. [ 'blacklist' => [401, 403] ],
  120. $this->createErrorMessage($e->getMessage())
  121. );
  122. }
  123. $this->attempts++;
  124. }
  125. // Retrieve credentials
  126. $result = null;
  127. while ($result == null) {
  128. try {
  129. $json = (yield $this->request(
  130. self::CRED_PATH . $this->profile,
  131. 'GET',
  132. $headers
  133. ));
  134. $result = $this->decodeResult($json);
  135. } catch (InvalidJsonException $e) {
  136. $this->handleRetryableException(
  137. $e,
  138. [ 'blacklist' => [401, 403] ],
  139. $this->createErrorMessage(
  140. 'Invalid JSON response, retries exhausted'
  141. )
  142. );
  143. } catch (TransferException $e) {
  144. // 401 indicates insecure flow not supported, switch to
  145. // attempting secure mode for subsequent calls
  146. if (!empty($this->getExceptionStatusCode($e))
  147. && $this->getExceptionStatusCode($e) === 401
  148. ) {
  149. $this->secureMode = true;
  150. }
  151. $this->handleRetryableException(
  152. $e,
  153. [ 'blacklist' => [401, 403] ],
  154. $this->createErrorMessage($e->getMessage())
  155. );
  156. }
  157. $this->attempts++;
  158. }
  159. yield new Credentials(
  160. $result['AccessKeyId'],
  161. $result['SecretAccessKey'],
  162. $result['Token'],
  163. strtotime($result['Expiration'])
  164. );
  165. });
  166. }
  167. /**
  168. * @param string $url
  169. * @param string $method
  170. * @param array $headers
  171. * @return PromiseInterface Returns a promise that is fulfilled with the
  172. * body of the response as a string.
  173. */
  174. private function request($url, $method = 'GET', $headers = [])
  175. {
  176. $disabled = getenv(self::ENV_DISABLE) ?: false;
  177. if (strcasecmp($disabled, 'true') === 0) {
  178. throw new CredentialsException(
  179. $this->createErrorMessage('EC2 metadata service access disabled')
  180. );
  181. }
  182. $fn = $this->client;
  183. $request = new Request($method, self::SERVER_URI . $url);
  184. $userAgent = 'aws-sdk-php/' . Sdk::VERSION;
  185. if (defined('HHVM_VERSION')) {
  186. $userAgent .= ' HHVM/' . HHVM_VERSION;
  187. }
  188. $userAgent .= ' ' . \Aws\default_user_agent();
  189. $request = $request->withHeader('User-Agent', $userAgent);
  190. foreach ($headers as $key => $value) {
  191. $request = $request->withHeader($key, $value);
  192. }
  193. return $fn($request, ['timeout' => $this->timeout])
  194. ->then(function (ResponseInterface $response) {
  195. return (string) $response->getBody();
  196. })->otherwise(function (array $reason) {
  197. $reason = $reason['exception'];
  198. if ($reason instanceof TransferException) {
  199. throw $reason;
  200. }
  201. $msg = $reason->getMessage();
  202. throw new CredentialsException(
  203. $this->createErrorMessage($msg)
  204. );
  205. });
  206. }
  207. private function handleRetryableException(
  208. \Exception $e,
  209. $retryOptions,
  210. $message
  211. ) {
  212. $isRetryable = true;
  213. if (!empty($status = $this->getExceptionStatusCode($e))
  214. && isset($retryOptions['blacklist'])
  215. && in_array($status, $retryOptions['blacklist'])
  216. ) {
  217. $isRetryable = false;
  218. }
  219. if ($isRetryable && $this->attempts < $this->retries) {
  220. sleep(pow(1.2, $this->attempts));
  221. } else {
  222. throw new CredentialsException($message);
  223. }
  224. }
  225. private function getExceptionStatusCode(\Exception $e)
  226. {
  227. if (method_exists($e, 'getResponse')
  228. && !empty($e->getResponse())
  229. ) {
  230. return $e->getResponse()->getStatusCode();
  231. }
  232. return null;
  233. }
  234. private function createErrorMessage($previous)
  235. {
  236. return "Error retrieving credentials from the instance profile "
  237. . "metadata service. ({$previous})";
  238. }
  239. private function decodeResult($response)
  240. {
  241. $result = json_decode($response, true);
  242. if (json_last_error() > 0) {
  243. throw new InvalidJsonException();
  244. }
  245. if ($result['Code'] !== 'Success') {
  246. throw new CredentialsException('Unexpected instance profile '
  247. . 'response code: ' . $result['Code']);
  248. }
  249. return $result;
  250. }
  251. }