diff --git a/src/Credential/Config.php b/src/Credential/Config.php index 3fa1608..f43902b 100644 --- a/src/Credential/Config.php +++ b/src/Credential/Config.php @@ -41,6 +41,10 @@ class Config public $expiration = 0; + public $enableIMDSv2 = false; + + public $metadataTokenDuration = 21600; + public function __construct($config) { foreach ($config as $k => $v) { diff --git a/src/EcsRamRoleCredential.php b/src/EcsRamRoleCredential.php index 08d8fda..fb19c26 100644 --- a/src/EcsRamRoleCredential.php +++ b/src/EcsRamRoleCredential.php @@ -21,16 +21,35 @@ class EcsRamRoleCredential implements CredentialsInterface */ private $roleName; + /** + * @var boolean + */ + private $enableIMDSv2; + + /** + * @var int + */ + private $metadataTokenDuration; + + /** * EcsRamRoleCredential constructor. * * @param $role_name */ - public function __construct($role_name = null) + public function __construct($role_name = null, $enable_IMDS_v2 = false, $metadata_token_duration = 21600 ) { Filter::roleName($role_name); $this->roleName = $role_name; + + Filter::enableIMDSv2($enable_IMDS_v2); + + $this->enableIMDSv2 = $enable_IMDS_v2; + + Filter::metadataTokenDuration($metadata_token_duration); + + $this->metadataTokenDuration = $metadata_token_duration; } /** @@ -116,7 +135,11 @@ public function getAccessKeyId() */ protected function getSessionCredential() { - return (new EcsRamRoleProvider($this))->get(); + $config = [ + 'enableIMDSv2' => $this->enableIMDSv2, + 'metadataTokenDuration' => $this->metadataTokenDuration, + ]; + return (new EcsRamRoleProvider($this, $config))->get(); } /** @@ -148,4 +171,5 @@ public function getExpiration() { return $this->getSessionCredential()->getExpiration(); } + } diff --git a/src/Filter.php b/src/Filter.php index 61b39e0..215695d 100644 --- a/src/Filter.php +++ b/src/Filter.php @@ -99,6 +99,23 @@ public static function roleName($role_name) } } + /** + * @param boolean|null $enable_IMDS_v2 + */ + public static function enableIMDSv2($enable_IMDS_v2) + { + if (!is_bool($enable_IMDS_v2)) { + throw new InvalidArgumentException('enable_IMDS_v2 must be a string'); + } + } + + + public static function metadataTokenDuration($metadata_token_duration) { + if (!is_int($metadata_token_duration)) { + throw new InvalidArgumentException('metadata_token_duration must be a int'); + } + } + /** * @param string $accessKeyId * @param string $accessKeySecret diff --git a/src/MockTrait.php b/src/MockTrait.php index a590ac7..41e8fce 100644 --- a/src/MockTrait.php +++ b/src/MockTrait.php @@ -6,6 +6,7 @@ use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Middleware; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -21,6 +22,11 @@ trait MockTrait */ private static $mockQueue = []; + /** + * @var array + */ + private static $history = []; + /** * @var MockHandler */ @@ -46,6 +52,14 @@ private static function createHandlerStack() self::$mock = new MockHandler(self::$mockQueue); } + /** + * @return MockHandler + */ + public static function getHandlerHistory() + { + return Middleware::history(self::$history); + } + /** * @param string $message * @param RequestInterface $request @@ -95,4 +109,12 @@ public static function getMock() { return self::$mock; } + + /** + * @return array + */ + public static function getHistroy() + { + return self::$history; + } } diff --git a/src/Providers/EcsRamRoleProvider.php b/src/Providers/EcsRamRoleProvider.php index 828bdad..28a33b9 100644 --- a/src/Providers/EcsRamRoleProvider.php +++ b/src/Providers/EcsRamRoleProvider.php @@ -2,6 +2,7 @@ namespace AlibabaCloud\Credentials\Providers; +use AlibabaCloud\Credentials\Helper; use AlibabaCloud\Credentials\Request\Request; use AlibabaCloud\Credentials\StsCredential; use Exception; @@ -26,10 +27,32 @@ class EcsRamRoleProvider extends Provider */ protected $expirationSlot = 10; + /** + * refresh time for meta server token. + * + * @var int + */ + private $staleTime = 0; + + /** + * @var string + */ + private $metadataHost = 'http://100.100.100.200'; + + /** + * @var string + */ + private $metadataToken; + /** * @var string */ - private $uri = 'http://100.100.100.200/latest/meta-data/ram/security-credentials/'; + private $ecsUri = '/latest/meta-data/ram/security-credentials/'; + + /** + * @var string + */ + private $metadataTokenUri = '/latest/api/token'; /** * Get credential. @@ -59,6 +82,19 @@ public function get() $result['SecurityToken'] ); } + + + protected function getEnableECSIMDSv2() + { + $enableIMDSv2 = Helper::envNotEmpty('ALIBABA_CLOUD_ECS_IMDSV2_ENABLE'); + if ($enableIMDSv2) { + return strtolower($enableIMDSv2) === 'false' ? false : (bool)$enableIMDSv2; + } + if(isset($this->config['enableIMDSv2'])) { + return $this->config['enableIMDSv2']; + } + return false; + } /** * Get credentials by request. @@ -70,13 +106,18 @@ public function get() public function request() { $credential = $this->credential; - $url = $this->uri . $credential->getRoleName(); + $url = $this->metadataHost . $this->ecsUri . $credential->getRoleName(); $options = [ 'http_errors' => false, 'timeout' => 1, 'connect_timeout' => 1, ]; + + if ($this->getEnableECSIMDSv2()) { + $this->refreshMetadataToken(); + $options['headers']['X-aliyun-ecs-metadata-token'] = $this->metadataToken; + } $result = Request::createClient()->request('GET', $url, $options); @@ -87,8 +128,54 @@ public function request() if ($result->getStatusCode() !== 200) { throw new RuntimeException('Error retrieving credentials from result: ' . $result->toJson()); - } + } return $result; } + + /** + * Get metadata token by request. + * + * @return ResponseInterface + * @throws Exception + * @throws GuzzleException + */ + protected function refreshMetadataToken() + { + if(!$this->needToRefresh()) { + return; + } + $credential = $this->credential; + $url = $this->metadataHost . $this->metadataTokenUri; + $tmpTime = $this->staleTime; + $this->staleTime = time() + $this->config['metadataTokenDuration']; + $options = [ + 'http_errors' => false, + 'timeout' => 1, + 'connect_timeout' => 1, + 'headers' => [ + 'X-aliyun-ecs-metadata-token-ttl-seconds' => $this->config['metadataTokenDuration'], + ], + ]; + + $result = Request::createClient()->request('PUT', $url, $options); + + if ($result->getStatusCode() != 200) { + $this->staleTime = $tmpTime; + throw new RuntimeException('Failed to get token from ECS Metadata Service. HttpCode= ' . $result->getStatusCode()); + } + + $this->metadataToken = $result->getBody(); + + return; + } + + + /** + * @return boolean + */ + protected function needToRefresh() + { + return \time() >= $this->staleTime; + } } diff --git a/src/Request/Request.php b/src/Request/Request.php index 1bf1418..6945e1b 100644 --- a/src/Request/Request.php +++ b/src/Request/Request.php @@ -140,6 +140,8 @@ public static function createClient() { if (Credentials::hasMock()) { $stack = HandlerStack::create(Credentials::getMock()); + $history = Credentials::getHandlerHistory(); + $stack->push($history); } else { $stack = HandlerStack::create(); } diff --git a/tests/Unit/CredentialTest.php b/tests/Unit/CredentialTest.php index c85995c..1eb056c 100644 --- a/tests/Unit/CredentialTest.php +++ b/tests/Unit/CredentialTest.php @@ -171,6 +171,25 @@ public function exceptionCases() 'role_name must be a string', ], + [ + [ + 'type' => 'ecs_ram_role', + 'role_name' => 'test', + 'enableIMDSv2' => 'false', + ], + 'enable_IMDS_v2 must be a string', + ], + + [ + [ + 'type' => 'ecs_ram_role', + 'role_name' => 'test', + 'enableIMDSv2' => false, + 'metadataTokenDuration' => 3600, + ], + 'metadata_token_duration must be a int', + ], + [ [ 'type' => 'ram_role_arn', diff --git a/tests/Unit/EcsRamRoleCredentialTest.php b/tests/Unit/EcsRamRoleCredentialTest.php index 4a626af..9b5a224 100644 --- a/tests/Unit/EcsRamRoleCredentialTest.php +++ b/tests/Unit/EcsRamRoleCredentialTest.php @@ -10,6 +10,7 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; use RuntimeException; +use ReflectionClass; class EcsRamRoleCredentialTest extends TestCase { @@ -47,6 +48,33 @@ public function testConstruct() $this->assertEquals($expected, (string)$credential); } + private function getPrivateField($instance, $field) { + $reflection = new ReflectionClass(EcsRamRoleCredential::class); + $privateProperty = $reflection->getProperty($field); + $privateProperty->setAccessible(true); + return $privateProperty->getValue($instance); + } + + /** + * @throws GuzzleException + */ + public function testConstructWithIMDSv2() + { + // Setup + $roleName = 'role_arn'; + $enableIMDSv2 = true; + $metadataTokenDuration = 3600; + $credential = new EcsRamRoleCredential($roleName, $enableIMDSv2, $metadataTokenDuration); + + self::assertEquals(true, $this->getPrivateField($credential, 'enableIMDSv2')); + self::assertEquals(3600, $this->getPrivateField($credential, 'metadataTokenDuration')); + + $credential = new EcsRamRoleCredential($roleName); + + self::assertEquals(false, $this->getPrivateField($credential, 'enableIMDSv2')); + self::assertEquals(21600, $this->getPrivateField($credential, 'metadataTokenDuration')); + } + /** * @throws GuzzleException */ diff --git a/tests/Unit/EcsRamRoleProviderTest.php b/tests/Unit/EcsRamRoleProviderTest.php new file mode 100644 index 0000000..6980980 --- /dev/null +++ b/tests/Unit/EcsRamRoleProviderTest.php @@ -0,0 +1,196 @@ + true, + 'metadataTokenDuration' => 3600, + ]; + + // Test + $credential = new EcsRamRoleCredential($roleName); + + $sessionCredential = new EcsRamRoleProvider($credential, $config); + + $sessionConfig = $this->getPrivateField($sessionCredential, 'config'); + + self::assertEquals(true, $sessionConfig['enableIMDSv2']); + self::assertEquals(3600, $sessionConfig['metadataTokenDuration']); + } + + + /** + * @throws Exception + */ + private function invokeProtectedFunc($instance, $method) { + $reflection = new ReflectionClass(EcsRamRoleProvider::class); + $method = $reflection->getMethod($method); + $method->setAccessible(true); + + $result = $method->invoke($instance); + return $result; + } + + /** + * @throws GuzzleException + */ + public function testGetEnableECSIMDSv2() + { + // Setup + $roleName = 'test'; + $config = [ + 'enableIMDSv2' => true, + 'metadataTokenDuration' => 3600, + ]; + + // Test + $credential = new EcsRamRoleCredential($roleName); + + $sessionCredential = new EcsRamRoleProvider($credential, $config); + + self::assertEquals(true, $this->invokeProtectedFunc($sessionCredential, 'getEnableECSIMDSv2')); + + $config = [ + 'metadataTokenDuration' => 3600, + ]; + + $sessionCredential = new EcsRamRoleProvider($credential, $config); + + self::assertEquals(false, $this->invokeProtectedFunc($sessionCredential, 'getEnableECSIMDSv2')); + + putenv('ALIBABA_CLOUD_ECS_IMDSV2_ENABLE=true'); + + self::assertEquals(true, $this->invokeProtectedFunc($sessionCredential, 'getEnableECSIMDSv2')); + + putenv('ALIBABA_CLOUD_ECS_IMDSV2_ENABLE=false'); + + self::assertEquals(false, $this->invokeProtectedFunc($sessionCredential, 'getEnableECSIMDSv2')); + + putenv('ALIBABA_CLOUD_ECS_IMDSV2_ENABLE='); + + self::assertEquals(false, $this->invokeProtectedFunc($sessionCredential, 'getEnableECSIMDSv2')); + } + + private function getPrivateField($instance, $field) { + $reflection = new ReflectionClass(EcsRamRoleProvider::class); + $privateProperty = $reflection->getProperty($field); + $privateProperty->setAccessible(true); + return $privateProperty->getValue($instance); + } + + /** + * @throws GuzzleException + */ + public function testRefreshMetadataTokenDefault() + { + // Setup + $roleName = 'test'; + $config = [ + 'enableIMDSv2' => true, + 'metadataTokenDuration' => 3600, + ]; + + // Test + $credential = new EcsRamRoleCredential($roleName); + + $sessionCredential = new EcsRamRoleProvider($credential, $config); + + Credentials::mockResponse(200, [], 'Token'); + + $this->invokeProtectedFunc($sessionCredential, 'refreshMetadataToken'); + + $histroy = Credentials::getHistroy(); + + $request = end($histroy)['request']; + $headers = $request->getHeaders(); + self::assertEquals('Token', $this->getPrivateField($sessionCredential, 'metadataToken')); + self::assertEquals('3600', $headers['X-aliyun-ecs-metadata-token-ttl-seconds'][0]); + } + + /** + * @expectedException RuntimeException + * @expectedExceptionMessage Failed to get token from ECS Metadata Service. HttpCode= 404 + * @throws GuzzleException + */ + public function testDefault404() + { + // Setup + $roleName = 'test'; + $config = [ + 'enableIMDSv2' => true, + 'metadataTokenDuration' => 3600, + ]; + + // Test + $credential = new EcsRamRoleCredential($roleName); + + $sessionCredential = new EcsRamRoleProvider($credential, $config); + + Credentials::mockResponse(404, [], 'Error'); + $this->invokeProtectedFunc($sessionCredential, 'refreshMetadataToken'); + + } + + /** + * @throws GuzzleException + */ + public function testNeedToRefresh() + { + // Setup + $roleName = 'test'; + $config = [ + 'enableIMDSv2' => true, + 'metadataTokenDuration' => 5, + ]; + + // Test + $credential = new EcsRamRoleCredential($roleName); + + $sessionCredential = new EcsRamRoleProvider($credential, $config); + + + + self::assertEquals(true, $this->invokeProtectedFunc($sessionCredential, 'needToRefresh')); + + Credentials::mockResponse(200, [], 'Token'); + + $this->invokeProtectedFunc($sessionCredential, 'refreshMetadataToken'); + + self::assertEquals(false, $this->invokeProtectedFunc($sessionCredential, 'needToRefresh')); + + sleep(3); + + self::assertEquals(false, $this->invokeProtectedFunc($sessionCredential, 'needToRefresh')); + + sleep(3); + + self::assertEquals(true, $this->invokeProtectedFunc($sessionCredential, 'needToRefresh')); + } +} diff --git a/tests/Unit/MockTraitTest.php b/tests/Unit/MockTraitTest.php index 31124da..0f27615 100644 --- a/tests/Unit/MockTraitTest.php +++ b/tests/Unit/MockTraitTest.php @@ -14,7 +14,11 @@ */ class MockTraitTest extends TestCase { - + public function setUp() + { + parent::setUp(); + Credentials::cancelMock(); + } /** * @throws \GuzzleHttp\Exception\GuzzleException * @expectedException \GuzzleHttp\Exception\RequestException