diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -80,6 +80,7 @@ 'PhutilAWSEC2Future' => 'future/aws/PhutilAWSEC2Future.php', 'PhutilAWSException' => 'future/aws/PhutilAWSException.php', 'PhutilAWSFuture' => 'future/aws/PhutilAWSFuture.php', + 'PhutilAWSS3Client' => 'future/aws/PhutilAWSS3Client.php', 'PhutilAWSS3Future' => 'future/aws/PhutilAWSS3Future.php', 'PhutilAggregateException' => 'error/PhutilAggregateException.php', 'PhutilAmazonAuthAdapter' => 'auth/PhutilAmazonAuthAdapter.php', diff --git a/src/future/aws/PhutilAWSFuture.php b/src/future/aws/PhutilAWSFuture.php --- a/src/future/aws/PhutilAWSFuture.php +++ b/src/future/aws/PhutilAWSFuture.php @@ -5,9 +5,13 @@ private $future; private $awsAccessKey; private $awsPrivateKey; + private $awsSessionToken; + private $awsSessionExpiry; private $awsRegion; private $builtRequest; private $params; + private $useIAM = false; + private $useSSL = false; abstract public function getServiceName(); @@ -15,41 +19,86 @@ parent::__construct(null); } - public function setAWSKeys($access, $private) { - $this->awsAccessKey = $access; - $this->awsPrivateKey = $private; - return $this; - } + public final function getAWSAccessKey() { + if ($this->useIAM) { + $this->refreshInstanceProfileCredentials(); + } - public function getAWSAccessKey() { return $this->awsAccessKey; } - public function getAWSPrivateKey() { + public final function getAWSPrivateKey() { + if ($this->useIAM) { + $this->refreshInstanceProfileCredentials(); + } + return $this->awsPrivateKey; } - public function getAWSRegion() { + public final function getAWSSessionToken() { + return $this->awsSessionToken; + } + + public final function getAWSSessionExpiry() { + return $this->awsSessionExpiry; + } + + public final function setAWSKeys($access, $private) { + $this->awsAccessKey = $access; + $this->awsPrivateKey = $private; + $this->awsSessionToken = null; + $this->awsSessionExpiry = null; + return $this; + } + + public final function getAWSRegion() { return $this->awsRegion; } - public function setAWSRegion($region) { + public final function setAWSRegion($region) { $this->awsRegion = $region; return $this; } public function getHost() { - $host = $this->getServiceName().'.'.$this->awsRegion.'.amazonaws.com'; - return $host; + return $this->getServiceName().'.'.$this->awsRegion.'.amazonaws.com'; } - public function setRawAWSQuery($action, array $params = array()) { + public function getPath() { + return ''; + } + + public final function setRawAWSQuery($action, array $params = array()) { $this->params = $params; $this->params['Action'] = $action; return $this; } - protected function getProxiedFuture() { + /** + * Set whether to use instance profile credentials for authentication. See + * http://docs.aws.amazon.com/IAM/latest/UserGuide/instance-profiles.html for + * more information + * + * @param bool True to use instance profile credentials, otherwise false. + * @return this + */ + public final function setUseIAM($use_iam) { + $this->useIAM = $use_iam; + + if ($this->useIAM) { + $this->awsAccessKey = null; + $this->awsPrivateKey = null; + } + + return $this; + } + + public final function setUseSSL($use_ssl) { + $this->useSSL = $use_ssl; + return $this; + } + + protected final function getProxiedFuture() { if (!$this->future) { $params = $this->params; @@ -57,26 +106,36 @@ throw new Exception('You must setRawAWSQuery()!'); } - if (!$this->getAWSAccessKey()) { - throw new Exception('You must setAWSKeys()!'); + if (!$this->getAWSAccessKey() && !$this->useIAM) { + throw new Exception('You must setAWSKeys() or setUseIAM(true)!'); } $params['AWSAccessKeyId'] = $this->getAWSAccessKey(); $params['Version'] = '2013-10-15'; $params['Timestamp'] = date('c'); + if ($this->useIAM) { + $this->refreshInstanceProfileCredentials(); + $params['SecurityToken'] = $this->awsSessionToken; + } + $params = $this->sign($params); - $uri = new PhutilURI('http://'.$this->getHost().'/'); - $uri->setQueryParams($params); + $uri = id(new PhutilURI()) + ->setDomain($this->getHost()) + ->setQueryParams($params); - $this->future = new HTTPFuture($uri); + if ($this->useSSL) { + $this->future = new HTTPSFuture($uri->setProtocol('https')); + } else { + $this->future = new HTTPFuture($uri->setProtocol('http')); + } } return $this->future; } - protected function didReceiveResult($result) { + protected final function didReceiveResult($result) { list($status, $body, $headers) = $result; try { @@ -110,7 +169,6 @@ * http://bit.ly/wU0JFh */ private function sign(array $params) { - $params['SignatureMethod'] = 'HmacSHA256'; $params['SignatureVersion'] = '2'; @@ -122,20 +180,74 @@ } $pstr = implode('&', $pstr); - $sign = "GET"."\n". - strtolower($this->getHost())."\n". - "/"."\n". - $pstr; - - $hash = hash_hmac( - 'sha256', - $sign, - $this->getAWSPrivateKey(), - $raw_ouput = true); + $sign = implode("\n", array( + 'GET', + strtolower($this->getHost()), + '/', + $pstr, + )); + $hash = hash_hmac('sha256', $sign, $this->getAWSPrivateKey(), true); $params['Signature'] = base64_encode($hash); return $params; } + /** + * Returns a URI which can be used to retrieve instance metadata from an AWS + * EC2 instance. See + * http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html + * for more information. + * + * @return PhutilURI + */ + protected final function getInstanceMetadataURL() { + return new PhutilURI('http://169.254.169.254/latest/meta-data/'); + } + + /** + * Refresh credentials from the instance profile. + * + * @return this + */ + private function refreshInstanceProfileCredentials() { + if (time() > $this->awsSessionExpiry) { + // Instance profile credentials have expired, retrieve a new token. + $this->setInstanceProfileCredentials(); + } + + return $this; + } + + /** + * Set credentials from the instance profile. + * + * http://bit.ly/1mLDoQ3 + */ + private function setInstanceProfileCredentials() { + $url = $this->getInstanceMetadataURL() + ->appendPath('/iam/security-credentials/'); + + // Get role. + $future = new HTTPFuture($url); + list($response, $headers) = $future->resolvex(); + $credentials = trim($response); + + // Get credentials. + $future = new HTTPFuture($url->appendPath($credentials)); + list($response, $headers) = $future->resolvex(); + $response = phutil_json_decode($response); + + if ($response['Code'] !== 'Success') { + throw new RuntimeException( + pht('Unexpected response code: "%s"', $response['Code'])); + } + + $this->awsAccessKey = $response['AccessKeyId']; + $this->awsPrivateKey = $response['SecretAccessKey']; + $this->awsSessionToken = $response['Token']; + $this->awsSessionExpiry = strtotime($response['Expiration']); + return $this; + } + } diff --git a/src/future/aws/PhutilAWSS3Client.php b/src/future/aws/PhutilAWSS3Client.php new file mode 100644 --- /dev/null +++ b/src/future/aws/PhutilAWSS3Client.php @@ -0,0 +1,68 @@ +future = new PhutilAWSS3Future(); + } + + public function setAWSKeys($access, $private) { + $this->future->setAWSKeys($access, $private); + } + + public function setUseIAM($use_iam) { + $this->future->setUseIAM($use_iam); + } + + private function getFuture() { + $future = clone $this->future; + return $future; + } + + public function deleteBucket($bucket) { + return $this->getFuture() + ->setBucket($bucket) + ->setMethod('DELETE') + ->resolvex(); + } + + public function deleteObject($bucket, $key) { + return $this->getFuture() + ->setBucket($bucket) + ->setKey($key) + ->setMethod('DELETE') + ->resolvex(); + } + + public function getObject($bucket, $key) { + return $this->getFuture() + ->setBucket($bucket) + ->setKey($key) + ->setMethod('GET') + ->resolvex(); + } + + public function listBucket($bucket) { + return $this->getFuture() + ->setBucket($bucket) + ->setMethod('GET') + ->resolvex(); + } + + public function putObject($data, $bucket, $key) { + $params = array( + 'data' => $data, + 'size' => strlen($data), + 'md5sum' => base64_encode(md5($data, true)), + ); + + return $this->getFuture() + ->setBucket($bucket) + ->setKey($key) + ->setMethod('PUT') + ->resolvex(); + } + +} diff --git a/src/future/aws/PhutilAWSS3Future.php b/src/future/aws/PhutilAWSS3Future.php --- a/src/future/aws/PhutilAWSS3Future.php +++ b/src/future/aws/PhutilAWSS3Future.php @@ -2,8 +2,37 @@ final class PhutilAWSS3Future extends PhutilAWSFuture { + private $bucket; + private $key; + public function getServiceName() { return 's3'; } + public function setBucket($bucket) { + $this->bucket = $bucket; + return $this; + } + + public function getBucket() { + return $this->bucket; + } + + public function setKey($key) { + $this->key = $key; + return $this; + } + + public function getKey() { + return $this->key; + } + + public final function getHost() { + return $this->getBucket().'.'.parent::getHost(); + } + + public function getPath() { + return $this->key; + } + }