diff --git a/.changes/nextrelease/feat-imds-custom-endpoint-support.json b/.changes/nextrelease/feat-imds-custom-endpoint-support.json new file mode 100644 index 0000000000..35c0400b09 --- /dev/null +++ b/.changes/nextrelease/feat-imds-custom-endpoint-support.json @@ -0,0 +1,7 @@ +[ + { + "type": "feature", + "category": "Credentials", + "description": "Adds support for specifying custom IMDS endpoint when using the InstanceProfileProvider." + } +] diff --git a/src/Credentials/CredentialsUtils.php b/src/Credentials/CredentialsUtils.php new file mode 100644 index 0000000000..b41666e292 --- /dev/null +++ b/src/Credentials/CredentialsUtils.php @@ -0,0 +1,35 @@ += $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..1c8aadefd9 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,11 @@ 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. + * The value must contain a valid URI scheme. If the URI scheme is not https, it must + * resolve to a loopback address. + * - 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 +82,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] ?? 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; } /** @@ -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,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 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', + ['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(gethostbyname($host)) + || in_array( + $uri, + [self::DEFAULT_METADATA_SERVICE_IPv4_ENDPOINT, self::DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT] + ); + } + + return true; + } } diff --git a/tests/Credentials/CredentialsTest.php b/tests/Credentials/CredentialsTest.php index 3637448ceb..248afd5633 100644 --- a/tests/Credentials/CredentialsTest.php +++ b/tests/Credentials/CredentialsTest.php @@ -5,7 +5,7 @@ use PHPUnit\Framework\TestCase; /** - * @covers Aws\Credentials\Credentials + * @covers \Aws\Credentials\Credentials */ class CredentialsTest extends TestCase { diff --git a/tests/Credentials/CredentialsUtilsTest.php b/tests/Credentials/CredentialsUtilsTest.php new file mode 100644 index 0000000000..ea97e7d9b1 --- /dev/null +++ b/tests/Credentials/CredentialsUtilsTest.php @@ -0,0 +1,58 @@ +assertEquals($expectedResult, $isLoopBack); + } + + /** + * @return string[] + */ + public function loopBackAddressCasesProvider(): array + { + return [ + 'IPv6_invalid_loopBack' => + [ + 'host' => '::2', + 'expected' => false + ], + 'IPv6_valid_loopBack' => + [ + 'host' => '::1', + 'expected' => true + ], + 'IPv4_invalid_loopBack' => + [ + 'host' => '192.168.0.1', + 'expected' => false + ], + 'IPv4_valid_loopBack' => + [ + 'host' => '127.0.0.1', + 'expected' => true + ], + 'IPv4_valid_loopBack_2' => + [ + 'host' => '127.0.0.255', + 'expected' => true + ], + ]; + } +} diff --git a/tests/Credentials/InstanceProfileProviderTest.php b/tests/Credentials/InstanceProfileProviderTest.php index a9a27c6d91..da21465dd4 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 invalid, or contains an unsupported host'); + $providerConfig = [ + InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_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!')]); + }; + } }