From 5d6492fd6ff105987a110f745793c37b7358673c 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 '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/CredentialsUtils.php | 37 +++ src/Credentials/EcsCredentialProvider.php | 33 +-- src/Credentials/InstanceProfileProvider.php | 133 ++++++++- .../InstanceProfileProviderTest.php | 274 ++++++++++++++++++ 4 files changed, 443 insertions(+), 34 deletions(-) create mode 100644 src/Credentials/CredentialsUtils.php diff --git a/src/Credentials/CredentialsUtils.php b/src/Credentials/CredentialsUtils.php new file mode 100644 index 0000000000..4c5af074d3 --- /dev/null +++ b/src/Credentials/CredentialsUtils.php @@ -0,0 +1,37 @@ += $loopbackStart && $ipLong <= $loopbackEnd); + } +} diff --git a/src/Credentials/EcsCredentialProvider.php b/src/Credentials/EcsCredentialProvider.php index 18c210547c..16d30708a6 100644 --- a/src/Credentials/EcsCredentialProvider.php +++ b/src/Credentials/EcsCredentialProvider.php @@ -158,7 +158,7 @@ private function getEcsUri() if (!empty($credFullUri)) return $credFullUri; } - + return self::SERVER_URI . $credsUri; } @@ -192,7 +192,7 @@ private function isCompatibleUri($uri) if ($host !== $ecsHost && $host !== $eksHost && $host !== self::EKS_SERVER_HOST_IPV6 - && !$this->isLoopbackAddress(gethostbyname($host)) + && !CredentialsUtils::isLoopBackAddress(gethostbyname($host)) ) { return false; } @@ -200,33 +200,4 @@ private function isCompatibleUri($uri) return true; } - - /** - * Determines whether or not a given host - * is a loopback address. - * - * @param $host - * - * @return bool - */ - private function isLoopbackAddress($host) - { - if (!filter_var($host, FILTER_VALIDATE_IP)) { - return false; - } - - if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { - if ($host === '::1') { - return true; - } - - return false; - } - - $loopbackStart = ip2long('127.0.0.0'); - $loopbackEnd = ip2long('127.255.255.255'); - $ipLong = ip2long($host); - - return ($ipLong >= $loopbackStart && $ipLong <= $loopbackEnd); - } } diff --git a/src/Credentials/InstanceProfileProvider.php b/src/Credentials/InstanceProfileProvider.php index e820a04cba..f54848f4fd 100644 --- a/src/Credentials/InstanceProfileProvider.php +++ b/src/Credentials/InstanceProfileProvider.php @@ -16,17 +16,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 +54,12 @@ class InstanceProfileProvider /** @var bool|null */ private $ec2MetadataV1Disabled; + /** @var string */ + private $endpoint; + + /** @var string */ + private $endpointMode; + /** * The constructor accepts the following options: * @@ -56,6 +67,9 @@ class InstanceProfileProvider * - profile: Optional EC2 profile name, if known. * - retries: Optional number of retries to be attempted. * - ec2_metadata_v1_disabled: Optional for disabling the fallback to IMDSv1. + * - endpoint: Optional for overriding the default endpoint to be used for fetching credentials. + * - endpoint_mode: Optional for overriding the default endpoint mode (IPv4|IPv6) to be used for + * resolving the default endpoint. * * @param array $config Configuration options. */ @@ -66,6 +80,12 @@ 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 =$config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT] ?? $config['endpoint'] ?? null; + if (!empty($this->endpoint) && !$this->isValidEndpoint($this->endpoint)) { + throw new \InvalidArgumentException('The provided URI "' . $this->endpoint . '" is not a valid URI scheme'); + } + + $this->endpointMode = $config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE] ?? $config['endpoint_mode'] ?? null; } /** @@ -227,7 +247,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 +334,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 +349,111 @@ 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->getDefaultEndpoint(), + 'string', + ['use_aws_shared_config_files' => true] + ); + } + + if (!$this->isValidEndpoint($endpoint)) { + throw new CredentialsException('The provided URI "' . $endpoint . '" is not a valid URI scheme'); + } + + 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', + ['use_aws_shared_config_files' => true] + ); + } + + 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. + * if the value for $uri is null. + * + * @return string|null + */ + private function isValidEndpoint( + string $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($host) + || in_array( + $uri, + [self::DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT, self::DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT] + ); + } + + return true; + } } diff --git a/tests/Credentials/InstanceProfileProviderTest.php b/tests/Credentials/InstanceProfileProviderTest.php index a9a27c6d91..151d74cda0 100644 --- a/tests/Credentials/InstanceProfileProviderTest.php +++ b/tests/Credentials/InstanceProfileProviderTest.php @@ -1349,4 +1349,278 @@ 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() : array + { + 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' => 'https://169.254.169.200', + 'endpoint_config' => 'http://[fd00:ec2::254]', + 'expected' => 'https://169.254.169.200' + ], + 'with_endpoint_config' => [ + 'endpoint_mode' => InstanceProfileProvider::ENDPOINT_MODE_IPv4, + 'endpoint_env' => null, + 'endpoint_config' => 'https://[fd00:ec2::200]', + 'expected' => 'https://[fd00:ec2::200]' + ] + ]; + } + + + public function testEndpointNotValid() + { + $invalidEndpoint = 'htt://10.0.0.1'; + $this->expectExceptionMessage('The provided URI "' . $invalidEndpoint . '" is not a valid URI scheme'); + $providerConfig = [ + 'endpoint' => $invalidEndpoint, + 'client' => $this->getClientForEndpointTesting(function ($uri) {/*Ignored!*/}) + ]; + $instanceProfileProvider = new InstanceProfileProvider($providerConfig); + $instanceProfileProvider()->wait(); + } + + /** + * 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(\Closure $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!')]); + }; + } }