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, 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
  • Loading branch information
yenfryherrerafeliz committed Dec 19, 2023
1 parent 63c7202 commit c4a0032
Show file tree
Hide file tree
Showing 3 changed files with 261 additions and 3 deletions.
87 changes: 84 additions & 3 deletions src/Credentials/InstanceProfileProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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/latest/';
private const DEFAULT_METADATA_SERVICE_IPv6_ENDPOINT = 'http://[fd00:ec2::254]/latest/';

/** @var string */
private $profile;
Expand All @@ -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:
*
Expand All @@ -66,6 +78,17 @@ 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 = Validator::validateURI(
$config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT] ?? null,
true,
'Aws\Exception\CredentialsException'
);
$this->endpointMode = Validator::validateInOptions(
$config[self::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE] ?? null,
[self::ENDPOINT_MODE_IPv4, self::ENDPOINT_MODE_IPv6],
true,
'Aws\Exception\CredentialsException'
);
}

/**
Expand Down Expand Up @@ -227,7 +250,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 +337,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 +352,62 @@ private function shouldFallbackToIMDSv1(): bool

return !$isImdsV1Disabled;
}

/**
* Resolves the metadata service endpoint.
*
* @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]
);
}

return Validator::validateURI($endpoint, false, 'Aws\Exception\CredentialsException');
}

/**
* Resolves the default metadata service endpoint.
*
* @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 Validator::validateInOptions($endpointMode, [self::ENDPOINT_MODE_IPv4, self::ENDPOINT_MODE_IPv6], false, 'Aws\Exception\CredentialsException');
}
}
47 changes: 47 additions & 0 deletions src/Credentials/Utils/Validator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Aws\Credentials\Utils;

final class Validator
{
private function __construct() {}

public static function validateURI(
$uri,
$ignoreNulls=true,
$exceptionClass='\InvalidArgumentException',
$errorMessage='The provided URI "$uri" is not a valid URI scheme'
)
{
if (is_null($uri) && $ignoreNulls) {
return $uri;
}

if (filter_var($uri, FILTER_VALIDATE_URL) === false) {
throw new $exceptionClass(str_replace('$uri', $uri, $errorMessage));
}

return $uri;
}

public static function validateInOptions(
$value,
$options,
$ignoreNulls=true,
$exceptionClass='\InvalidArgumentException',
$errorMessage='The provided value $value is not valid option of [$options]'
)
{
if (is_null($value) && $ignoreNulls) {
return $value;
}

if (!in_array($value, $options)) {
$errorMessage = str_replace('$value', $value, $errorMessage);
$errorMessage = str_replace('$options', implode(',', $options), $errorMessage);
throw new $exceptionClass($errorMessage);
}

return $value;
}
}
130 changes: 130 additions & 0 deletions tests/Credentials/InstanceProfileProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1349,4 +1349,134 @@ private function fetchMockedCredentialsAndAlwaysExpectAToken($config=[]) {
return false;
}
}

/**
* @dataProvider endpointModeCases
*/
public function testEndpointMode($clientConfig, $env, $config, $expected)
{
$deferTasks = [];
$providerConfig = [];
if (!is_null($clientConfig)) {
$providerConfig[InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE] = $clientConfig;
}

if (!is_null($env)) {
$currentEndpointMode = ConfigurationResolver::env(InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE, 'string');
putenv('AWS_' . strtoupper(InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE) . '=' . $env);
$deferTasks[] = function () use ($currentEndpointMode) {
putenv('AWS_' . strtoupper(InstanceProfileProvider::CFG_EC2_METADATA_SERVICE_ENDPOINT_MODE) . '=' . $currentEndpointMode);
};
}

if (!is_null($config)) {
$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 . "=" . $config;
file_put_contents($mockConfigFile, $configContent);
$deferTasks[] = function () use ($mockConfigFile, $currentConfigFile) {
unlink($mockConfigFile);
putenv(ConfigurationResolver::ENV_CONFIG_FILE . '=' . $currentConfigFile);
};
}

try {
$providerConfig += [
'client' => function (RequestInterface $request) use ($expected) {
$host = $request->getUri()->getHost();
switch ($expected) {
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 ($request->getMethod() === 'PUT' && $request->getUri()->getPath() === '/latest/api/token') {
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!')]);
}
];
$instanceProfileProvider = new InstanceProfileProvider($providerConfig);
$instanceProfileProvider()->wait();
} finally {
foreach ($deferTasks as $task) {
$task();
}
}
}

private function endpointModeCases() : 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
]
];
}
}

0 comments on commit c4a0032

Please sign in to comment.