From 24dd452513bb15c1df2f45f904bd08090bd1f5d4 Mon Sep 17 00:00:00 2001 From: Yenfry Herrera Feliz Date: Mon, 18 Dec 2023 06:56:20 -0800 Subject: [PATCH] feat: IMDS support for providing custom endpoint When using IMDS for fetching credentials, customers should be able to provide their custom endpoint when desired, and that is what this change does. Basically, customer can provide a custom endpoint by doing one of the following options: Please note that a valid URI value needs to be provided, otherwise a credential exception will be thrown. - Providing a parameter called 'ec2_metadata_service_endpoint' to the constructor of the InstanceProfileProvider. - By setting an environment variable called AWS_EC2_METADATA_SERVICE_ENDPOINT. - By defining a key-value config in the config file ~/.aws/config --- src/Credentials/InstanceProfileProvider.php | 156 ++++++++++- .../InstanceProfileProviderTest.php | 261 ++++++++++++++++++ 2 files changed, 414 insertions(+), 3 deletions(-) diff --git a/src/Credentials/InstanceProfileProvider.php b/src/Credentials/InstanceProfileProvider.php index e820a04cba..079a82c908 100644 --- a/src/Credentials/InstanceProfileProvider.php +++ b/src/Credentials/InstanceProfileProvider.php @@ -2,6 +2,7 @@ namespace Aws\Credentials; use Aws\Configuration\ConfigurationResolver; +use Aws\Credentials\Utils\Validator; use Aws\Exception\CredentialsException; use Aws\Exception\InvalidJsonException; use Aws\Sdk; @@ -16,17 +17,22 @@ */ class InstanceProfileProvider { - const SERVER_URI = 'http://169.254.169.254/latest/'; const CRED_PATH = 'meta-data/iam/security-credentials/'; const TOKEN_PATH = 'api/token'; const ENV_DISABLE = 'AWS_EC2_METADATA_DISABLED'; const ENV_TIMEOUT = 'AWS_METADATA_SERVICE_TIMEOUT'; const ENV_RETRIES = 'AWS_METADATA_SERVICE_NUM_ATTEMPTS'; const CFG_EC2_METADATA_V1_DISABLED = 'ec2_metadata_v1_disabled'; + const CFG_EC2_METADATA_SERVICE_ENDPOINT = 'ec2_metadata_service_endpoint'; + const CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE = 'ec2_metadata_service_endpoint_mode'; const DEFAULT_TIMEOUT = 1.0; const DEFAULT_RETRIES = 3; const DEFAULT_TOKEN_TTL_SECONDS = 21600; const DEFAULT_AWS_EC2_METADATA_V1_DISABLED = false; + const ENDPOINT_MODE_IPv4 = 'IPv4'; + const ENDPOINT_MODE_IPv6 = 'IPv6'; + private const DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT = 'http://169.254.169.254'; + private const DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT = 'http://[fd00:ec2::254]'; /** @var string */ private $profile; @@ -49,6 +55,12 @@ class InstanceProfileProvider /** @var bool|null */ private $ec2MetadataV1Disabled; + /** @var string */ + private $endpoint; + + /** @var string */ + private $endpointMode; + /** * The constructor accepts the following options: * @@ -66,6 +78,16 @@ public function __construct(array $config = []) $this->retries = (int) getenv(self::ENV_RETRIES) ?: ($config['retries'] ?? self::DEFAULT_RETRIES); $this->client = $config['client'] ?? \Aws\default_http_handler(); $this->ec2MetadataV1Disabled = $config[self::CFG_EC2_METADATA_V1_DISABLED] ?? null; + $this->endpoint = $this->validateURI( + $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT] ?? $config['endpoint'] ?? null, + true, + 'Aws\Exception\CredentialsException' + ); + $this->endpointMode = $this->validateEndpointMode( + $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE] ?? $config['endpoint_mode'] ?? null, + true, + 'Aws\Exception\CredentialsException' + ); } /** @@ -227,7 +249,7 @@ private function request($url, $method = 'GET', $headers = []) } $fn = $this->client; - $request = new Request($method, self::SERVER_URI . $url); + $request = new Request($method, $this->resolveEndpoint() . $url); $userAgent = 'aws-sdk-php/' . Sdk::VERSION; if (defined('HHVM_VERSION')) { $userAgent .= ' HHVM/' . HHVM_VERSION; @@ -314,7 +336,7 @@ private function decodeResult($response) * * @return bool */ - private function shouldFallbackToIMDSv1(): bool + private function shouldFallbackToIMDSv1(): bool { $isImdsV1Disabled = \Aws\boolean_value($this->ec2MetadataV1Disabled) ?? \Aws\boolean_value( @@ -329,4 +351,132 @@ private function shouldFallbackToIMDSv1(): bool 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->defaultEndpoint(), + 'string', + ['use_aws_shared_config_files' => true] + ); + } + + if (str_ends_with('/', $endpoint) === false) { + $endpoint = $endpoint . '/'; + } + + $endpoint = $endpoint . 'latest/'; + + return $this->validateURI($endpoint, false, 'Aws\Exception\CredentialsException'); + } + + /** + * 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 defaultEndpoint(): 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', + ['use_aws_shared_config_files' => true] + ); + } + + return $this->validateEndpointMode($endpointMode, false, 'Aws\Exception\CredentialsException'); + } + + /** + * This method checks for whether a provide URI is valid. + * @param string $uri this parameter is the uri to do the validation against to. + * @param bool $ignoreNulls when this parameter is false then, an exception will be thrown + * if the value for $uri is null. + * @param string $exceptionClass the exception class to be used in case of errors. + * + * @return string|null + */ + private function validateURI( + $uri, + $ignoreNulls=true, + $exceptionClass='\InvalidArgumentException' + ): ?string + { + if (is_null($uri) && $ignoreNulls) { + return $uri; + } + + if (filter_var($uri, FILTER_VALIDATE_URL) === false) { + throw new $exceptionClass(str_replace('$uri', $uri, 'The provided URI "$uri" is not a valid URI scheme')); + } + + return $uri; + } + + /** + * This method makes sure that the endpoint mode is either IPv4 or IPv6. + * @param string $endpointMode the endpoint mode to the validation against to. + * @param bool $ignoreNulls when this parameter is false then, an exception will be thrown + * if the value for $endpointMode is null. + * @param $exceptionClass + * + * @return string|null + */ + private function validateEndpointMode( + $endpointMode, + $ignoreNulls=true, + $exceptionClass='\InvalidArgumentException' + ): ?string + { + $options = [self::ENDPOINT_MODE_IPv4, self::ENDPOINT_MODE_IPv6]; + if (is_null($endpointMode) && $ignoreNulls) { + return $endpointMode; + } + + if (!in_array($endpointMode, $options)) { + $errorMessage = str_replace('$value', $endpointMode, 'The provided value $value is not valid option of [$options]'); + $errorMessage = str_replace('$options', implode(',', $options), $errorMessage); + throw new $exceptionClass($errorMessage); + } + + return $endpointMode; + } } diff --git a/tests/Credentials/InstanceProfileProviderTest.php b/tests/Credentials/InstanceProfileProviderTest.php index a9a27c6d91..0408c51f2c 100644 --- a/tests/Credentials/InstanceProfileProviderTest.php +++ b/tests/Credentials/InstanceProfileProviderTest.php @@ -1349,4 +1349,265 @@ private function fetchMockedCredentialsAndAlwaysExpectAToken($config=[]) { return false; } } + + /** + * This test checks for endpoint resolution mode based on the different sources + * from which this option can be configured/customized. + * @param string $endpointModeClientConfig if this parameter is not null then, we will set this + * parameter within the client config parameters. + * @param string $endpointModeEnv if this parameter is not null then, we will set its value in an + * environment variable called "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE". + * @param string $endpointModeConfig if this parameter is not null then, we will set its value within + * a test config file with the property name ec2_metadata_service_endpoint_mode, and we will make + * the ConfigurationResolver to resolve configuration from that test config file by setting AWS_CONFIG_FILE to the + * test config file name. + * @param string $expectedEndpointMode this parameter is the endpoint mode that is expected to be resolved by + * the credential provider. + * + * @dataProvider endpointModeCasesProvider + */ + public function testEndpointModeResolution($endpointModeClientConfig, $endpointModeEnv, $endpointModeConfig, $expectedEndpointMode) + { + $deferredTasks = []; + $providerConfig = [ + 'client' => $this->getClientForEndpointTesting(function ($uri) use ($expectedEndpointMode) { + $host = $uri->getHost(); + switch ($expectedEndpointMode) { + case InstanceProfileProvider::ENDPOINT_MODE_IPv4: + // If endpointMode is expected to be IPv4 then, the resolved endpoint should be IPv4 + $this->assertTrue(filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false); + break; + case InstanceProfileProvider::ENDPOINT_MODE_IPv6: + // If endpointMode is expected to be IPv6 then, the resolved endpoint should be IPv6 + $hostWithoutBrackets = trim($host, '[]'); + $this->assertTrue(filter_var($hostWithoutBrackets, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false); + break; + default: + $this->fail("The expected value for endpoint_mode should be either one of the following options[" . InstanceProfileProvider::ENDPOINT_MODE_IPv4 . ', ' . InstanceProfileProvider::ENDPOINT_MODE_IPv6 . "]"); + } + }) + ]; + if (!is_null($endpointModeClientConfig)) { + $providerConfig[InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE] = $endpointModeClientConfig; + } + + if (!is_null($endpointModeEnv)) { + $currentEndpointMode = ConfigurationResolver::env(InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE, 'string'); + putenv('AWS_' . strtoupper(InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE) . '=' . $endpointModeEnv); + $deferredTasks[] = function () use ($currentEndpointMode) { + putenv('AWS_' . strtoupper(InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE) . '=' . $currentEndpointMode); + }; + } + + if (!is_null($endpointModeConfig)) { + $currentConfigFile = getenv(ConfigurationResolver::ENV_CONFIG_FILE); + $mockConfigFile = "./mock-config"; + putenv(ConfigurationResolver::ENV_CONFIG_FILE . '=' . $mockConfigFile); + $configContent = "[default]" . "\n" . InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE . "=" . $endpointModeConfig; + file_put_contents($mockConfigFile, $configContent); + $deferredTasks[] = function () use ($mockConfigFile, $currentConfigFile) { + unlink($mockConfigFile); + putenv(ConfigurationResolver::ENV_CONFIG_FILE . '=' . $currentConfigFile); + }; + } + + try { + $instanceProfileProvider = new InstanceProfileProvider($providerConfig); + $instanceProfileProvider()->wait(); + } finally { + foreach ($deferredTasks as $task) { + $task(); + } + } + } + + /** + * This method is the data provider that returns the different scenarios + * for resolving the endpoint mode. + * + * @return array[] + */ + public function endpointModeCasesProvider() : array + { + return [ + 'endpoint_mode_not_specified' => [ + 'client_configuration' => null, + 'environment_variable' => null, + 'config' => null, + 'expected' => InstanceProfileProvider::ENDPOINT_MODE_IPv4 + ], + 'endpoint_mode_ipv4_client_config' => [ + 'client_configuration' => InstanceProfileProvider::ENDPOINT_MODE_IPv4, + 'environment_variable' => InstanceProfileProvider::ENDPOINT_MODE_IPv6, + 'config' => InstanceProfileProvider::ENDPOINT_MODE_IPv6, + 'expected' => InstanceProfileProvider::ENDPOINT_MODE_IPv4 + ], + 'endpoint_mode_ipv6_client_config' => [ + 'client_configuration' => InstanceProfileProvider::ENDPOINT_MODE_IPv6, + 'environment_variable' => InstanceProfileProvider::ENDPOINT_MODE_IPv4, + 'config' => InstanceProfileProvider::ENDPOINT_MODE_IPv4, + 'expected' => InstanceProfileProvider::ENDPOINT_MODE_IPv6 + ], + 'endpoint_mode_ipv4_env' => [ + 'client_configuration' => null, + 'environment_variable' => InstanceProfileProvider::ENDPOINT_MODE_IPv4, + 'config' => InstanceProfileProvider::ENDPOINT_MODE_IPv6, + 'expected' => InstanceProfileProvider::ENDPOINT_MODE_IPv4 + ], + 'endpoint_mode_ipv6_env' => [ + 'client_configuration' => null, + 'environment_variable' => InstanceProfileProvider::ENDPOINT_MODE_IPv6, + 'config' => InstanceProfileProvider::ENDPOINT_MODE_IPv4, + 'expected' => InstanceProfileProvider::ENDPOINT_MODE_IPv6 + ], + 'endpoint_mode_ipv4_config' => [ + 'client_configuration' => null, + 'environment_variable' => null, + 'config' => InstanceProfileProvider::ENDPOINT_MODE_IPv4, + 'expected' => InstanceProfileProvider::ENDPOINT_MODE_IPv4 + ], + 'endpoint_mode_ipv6_config' => [ + 'client_configuration' => null, + 'environment_variable' => null, + 'config' => InstanceProfileProvider::ENDPOINT_MODE_IPv6, + 'expected' => InstanceProfileProvider::ENDPOINT_MODE_IPv6 + ] + ]; + } + + /** + * This test checks for endpoint resolution based on the different sources from + * which this option can be configured/customized. + * @param string $endpointMode the endpoint mode that we will be used to resolve + * the default endpoint, in case the endpoint is not explicitly specified. + * @param string $endpointEnv if this parameter is not null then we will set its value + * in an environment variable called AWS_EC2_METADATA_SERVICE_ENDPOINT. + * @param string $endpointConfig if this parameter is not null then, we will set its value within + * a test config file with the property name ec2_metadata_service_endpoint_mode, and we will make + * the ConfigurationResolver to resolve configuration from that test config file by setting AWS_CONFIG_FILE to the + * test config file name. + * @param string $expectedEndpoint this parameter is the endpoint that is expected to be resolved + * by the credential provider. + * + * @dataProvider endpointCasesProvider + */ + public function testEndpointResolution($endpointMode, $endpointEnv, $endpointConfig, $expectedEndpoint) + { + $providerConfig = [ + InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE => $endpointMode, + 'client' => $this->getClientForEndpointTesting(function ($uri) use ($expectedEndpoint) { + $endpoint = $uri->getScheme() . '://' . $uri->getHost(); + $this->assertSame($expectedEndpoint, $endpoint); + }) + ]; + $deferredTasks = []; + if (!is_null($endpointEnv)) { + $currentEndpointEnv = ConfigurationResolver::env(InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT, 'string'); + putenv('AWS_' . strtoupper(InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT) . '=' . $endpointEnv); + $deferredTasks[] = function () use ($currentEndpointEnv) { + putenv('AWS_' . strtoupper(InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT) . '=' . $currentEndpointEnv); + }; + } + + if (!is_null($endpointConfig)) { + $currentConfigFile = getenv(ConfigurationResolver::ENV_CONFIG_FILE); + $mockConfigFile = "./mock-config"; + putenv(ConfigurationResolver::ENV_CONFIG_FILE . '=' . $mockConfigFile); + $configContent = "[default]" . "\n" . InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT . "=" . $endpointConfig; + file_put_contents($mockConfigFile, $configContent); + $deferredTasks[] = function () use ($mockConfigFile, $currentConfigFile) { + unlink($mockConfigFile); + putenv(ConfigurationResolver::ENV_CONFIG_FILE . '=' . $currentConfigFile); + }; + } + + try { + $instanceProfileProvider = new InstanceProfileProvider($providerConfig); + $instanceProfileProvider()->wait(); + } finally { + foreach ($deferredTasks as $task) { + $task(); + } + } + } + + /** + * This method is the data provider that returns the different scenarios + * for resolving endpoint. + * + * @return array[] + */ + public function endpointCasesProvider() + { + return [ + 'with_endpoint_mode_ipv4' => [ + 'endpoint_mode' => InstanceProfileProvider::ENDPOINT_MODE_IPv4, + 'endpoint_env' => null, + 'endpoint_config' => null, + 'expected' => 'http://169.254.169.254' + ], + 'with_endpoint_mode_ipv6' => [ + 'endpoint_mode' => InstanceProfileProvider::ENDPOINT_MODE_IPv6, + 'endpoint_env' => null, + 'endpoint_config' => null, + 'expected' => 'http://[fd00:ec2::254]' + ], + 'with_endpoint_env' => [ + 'endpoint_mode' => InstanceProfileProvider::ENDPOINT_MODE_IPv6, + 'endpoint_env' => 'http://169.254.169.200', + 'endpoint_config' => 'http://[fd00:ec2::254]', + 'expected' => 'http://169.254.169.200' + ], + 'with_endpoint_config' => [ + 'endpoint_mode' => InstanceProfileProvider::ENDPOINT_MODE_IPv4, + 'endpoint_env' => null, + 'endpoint_config' => 'http://[fd00:ec2::200]', + 'expected' => 'http://[fd00:ec2::200]' + ] + ]; + } + + /** + * This method returns a test http handler which is intended to be used + * for testing endpoint and endpoint mode resolution. The way it works is + * that it receives an assertion function that is called within the first + * request done by the instance profile provider, and to which we pass the + * uri of the request as the parameter. + * + * @param \Closure $assertingFunction the assertion function which should + * holds the assertions to be done. This function should expect the uri of + * the request as a parameter. + * + * @return \Closure + */ + private function getClientForEndpointTesting($assertingFunction): \Closure + { + return function (RequestInterface $request) use ($assertingFunction) { + if ($request->getMethod() === 'PUT' && $request->getUri()->getPath() === '/latest/api/token') { + // Here is where we call the assertions provided as function. + $assertingFunction($request->getUri()); + + return Promise\Create::promiseFor(new Response(200, [], Psr7\Utils::streamFor(''))); + } elseif ($request->getMethod() === 'GET') { + switch ($request->getUri()->getPath()) { + case '/latest/meta-data/iam/security-credentials/': + return Promise\Create::promiseFor(new Response(200, [], Psr7\Utils::streamFor('MockProfile'))); + case '/latest/meta-data/iam/security-credentials/MockProfile': + $expiration = time() + 10000; + + return Promise\Create::promiseFor( + new Response( + 200, + [], + Psr7\Utils::streamFor( + json_encode($this->getCredentialArray('foo', 'baz', null, "@$expiration")) + ) + ) + ); + } + } + + return Promise\Create::rejectionFor(['exception' => new \Exception('Unexpected error!')]); + }; + } }