Skip to content

Commit

Permalink
feat: IMDS support for providing custom endpoint
Browse files Browse the repository at this point in the history
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 as endpoint, 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 with the desired custom endpoint.
- By defining a key-value config in the config file ~/.aws/config where its key is ec2_metadata_service_endpoint and its value is the desired custom endpoint.

This commit also includes customizing the endpoint mode, which is used to change how the default endpoint is resolved. The valid endpoint mode are IPv4 and IPv6. Here are the different options for setting the endpoint mode:
- Providing a parameter called 'ec2_metadata_service_endpoint_mode' in the constructor parameters for InstanceProfileProvider.
- By setting an environment variable called AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE with the desired custom endpoint mode.
- By defining a key-value config in the config file ~/.aws/config where its key is ec2_metadata_service_endpoint_mode and its value is the desired custom endpoint mode.
Please note that the reason why the parameter we pass in the constructor is not 'endpoint' is because the parameter name conflicts with service client configurations, and when using the default credential resolution internally we pass the client configuration to the credential provider, and if a custom endpoint was provided for the service client it will also pass this custom endpoint to the IMDS credential provider, which will make it to fail.
  • Loading branch information
yenfryherrerafeliz committed Jan 22, 2024
1 parent 63c7202 commit 21a1125
Show file tree
Hide file tree
Showing 7 changed files with 509 additions and 35 deletions.
7 changes: 7 additions & 0 deletions .changes/nextrelease/feat-imds-custom-endpoint-support.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[
{
"type": "feature",
"category": "Credentials",
"description": "Adds support for specifying custom IMDS endpoint when using the InstanceProfileProvider."
}
]
35 changes: 35 additions & 0 deletions src/Credentials/CredentialsUtils.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace Aws\Credentials;

final class CredentialsUtils
{
/**
* Determines whether a given host
* is a loopback address.
*
* @param $host
*
* @return bool
*/
public static function isLoopBackAddress($host): bool
{
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);
}
}
33 changes: 2 additions & 31 deletions src/Credentials/EcsCredentialProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ private function getEcsUri()
if (!empty($credFullUri))
return $credFullUri;
}

return self::SERVER_URI . $credsUri;
}

Expand Down Expand Up @@ -192,41 +192,12 @@ 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;
}
}

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);
}
}
135 changes: 132 additions & 3 deletions src/Credentials/InstanceProfileProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -49,13 +54,24 @@ class InstanceProfileProvider
/** @var bool|null */
private $ec2MetadataV1Disabled;

/** @var string */
private $endpoint;

/** @var string */
private $endpointMode;

/**
* The constructor accepts the following options:
*
* - timeout: Connection timeout, in seconds.
* - 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.
*/
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}
}
2 changes: 1 addition & 1 deletion tests/Credentials/CredentialsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use PHPUnit\Framework\TestCase;

/**
* @covers Aws\Credentials\Credentials
* @covers \Aws\Credentials\Credentials
*/
class CredentialsTest extends TestCase
{
Expand Down
58 changes: 58 additions & 0 deletions tests/Credentials/CredentialsUtilsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php
namespace Aws\Test\Credentials;

use Aws\Credentials\CredentialsUtils;
use PHPUnit\Framework\TestCase;

/**
* @covers \Aws\Credentials\CredentialsUtils
*/
class CredentialsUtilsTest extends TestCase
{

/**
* @param string $host
* @param bool $expectedResult
*
* @dataProvider loopBackAddressCasesProvider
*/
public function testLoopBackAddressCases(string $host, bool $expectedResult)
{
$isLoopBack = CredentialsUtils::isLoopBackAddress($host);
$this->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
],
];
}
}
Loading

0 comments on commit 21a1125

Please sign in to comment.