diff --git a/src/conduit/ConduitClient.php b/src/conduit/ConduitClient.php index 065a42ab..441fe25c 100644 --- a/src/conduit/ConduitClient.php +++ b/src/conduit/ConduitClient.php @@ -1,395 +1,417 @@ connectionID; } public function __construct($uri) { $this->uri = new PhutilURI($uri); if (!strlen($this->uri->getDomain())) { throw new Exception( pht("Conduit URI '%s' must include a valid host.", $uri)); } $this->host = $this->uri->getDomain(); } /** * Override the domain specified in the service URI and provide a specific * host identity. * * This can be used to connect to a specific node in a cluster environment. */ public function setHost($host) { $this->host = $host; return $this; } public function getHost() { return $this->host; } public function setConduitToken($conduit_token) { $this->conduitToken = $conduit_token; return $this; } public function getConduitToken() { return $this->conduitToken; } public function setOAuthToken($oauth_token) { $this->oauthToken = $oauth_token; return $this; } public function callMethodSynchronous($method, array $params) { return $this->callMethod($method, $params)->resolve(); } public function didReceiveResponse($method, $data) { if ($method == 'conduit.connect') { $this->sessionKey = idx($data, 'sessionKey'); $this->connectionID = idx($data, 'connectionID'); } return $data; } public function setTimeout($timeout) { $this->timeout = $timeout; return $this; } public function setSigningKeys( $public_key, PhutilOpaqueEnvelope $private_key) { $this->publicKey = $public_key; $this->privateKey = $private_key; return $this; } + public function enableCapabilities(array $capabilities) { + $this->capabilities += array_fuse($capabilities); + return $this; + } + public function callMethod($method, array $params) { $meta = array(); if ($this->sessionKey) { $meta['sessionKey'] = $this->sessionKey; } if ($this->connectionID) { $meta['connectionID'] = $this->connectionID; } if ($method == 'conduit.connect') { $certificate = idx($params, 'certificate'); if ($certificate) { $token = time(); $params['authToken'] = $token; $params['authSignature'] = sha1($token.$certificate); } unset($params['certificate']); } if ($this->privateKey && $this->publicKey) { $meta['auth.type'] = self::AUTH_ASYMMETRIC; $meta['auth.key'] = $this->publicKey; $meta['auth.host'] = $this->getHostStringForSignature(); $signature = $this->signRequest($method, $params, $meta); $meta['auth.signature'] = $signature; } if ($this->conduitToken) { $meta['token'] = $this->conduitToken; } if ($this->oauthToken) { $meta['access_token'] = $this->oauthToken; } if ($meta) { $params['__conduit__'] = $meta; } $uri = id(clone $this->uri)->setPath('/api/'.$method); $data = array( 'params' => json_encode($params), 'output' => 'json', // This is a hint to Phabricator that the client expects a Conduit // response. It is not necessary, but provides better error messages in // some cases. '__conduit__' => true, ); // Always use the cURL-based HTTPSFuture, for proxy support and other // protocol edge cases that HTTPFuture does not support. - $core_future = new HTTPSFuture($uri, $data); + $core_future = new HTTPSFuture($uri); $core_future->addHeader('Host', $this->getHostStringForHeader()); $core_future->setMethod('POST'); $core_future->setTimeout($this->timeout); + // See T13507. If possible, try to compress requests. To compress requests, + // we must have "gzencode()" available and the server needs to have + // asserted it has the "gzip" capability. + $can_gzip = + (function_exists('gzencode')) && + (isset($this->capabilities['gzip'])); + if ($can_gzip) { + $gzip_data = phutil_build_http_querystring($data); + $gzip_data = gzencode($gzip_data); + + $core_future->addHeader('Content-Encoding', 'gzip'); + $core_future->setData($gzip_data); + } else { + $core_future->setData($data); + } + if ($this->username !== null) { $core_future->setHTTPBasicAuthCredentials( $this->username, $this->password); } return id(new ConduitFuture($core_future)) ->setClient($this, $method); } public function setBasicAuthCredentials($username, $password) { $this->username = $username; $this->password = new PhutilOpaqueEnvelope($password); return $this; } private function getHostStringForHeader() { return $this->newHostString(false); } private function getHostStringForSignature() { return $this->newHostString(true); } /** * Build a string describing the host for this request. * * This method builds strings in two modes: with explicit ports for request * signing (which always include the port number) and with implicit ports * for use in the "Host:" header of requests (which omit the port number if * the port is the same as the default port for the protocol). * * This implicit port behavior is similar to what browsers do, so it is less * likely to get us into trouble with webserver configurations. * * @param bool True to include the port explicitly. * @return string String describing the host for the request. */ private function newHostString($with_explicit_port) { $host = $this->getHost(); $uri = new PhutilURI($this->uri); $protocol = $uri->getProtocol(); $port = $uri->getPort(); $implicit_ports = array( 'https' => 443, ); $default_port = 80; $implicit_port = idx($implicit_ports, $protocol, $default_port); if ($with_explicit_port) { if (!$port) { $port = $implicit_port; } } else { if ($port == $implicit_port) { $port = null; } } if (!$port) { $result = $host; } else { $result = $host.':'.$port; } return $result; } private function signRequest( $method, array $params, array $meta) { $input = self::encodeRequestDataForSignature( $method, $params, $meta); $signature = null; $result = openssl_sign( $input, $signature, $this->privateKey->openEnvelope()); if (!$result) { throw new Exception( pht('Unable to sign Conduit request with signing key.')); } return self::SIGNATURE_CONSIGN_1.base64_encode($signature); } public static function verifySignature( $method, array $params, array $meta, $openssl_public_key) { $auth_type = idx($meta, 'auth.type'); switch ($auth_type) { case self::AUTH_ASYMMETRIC: break; default: throw new Exception( pht( 'Unable to verify request signature, specified "%s" '. '("%s") is unknown.', 'auth.type', $auth_type)); } $public_key = idx($meta, 'auth.key'); if (!strlen($public_key)) { throw new Exception( pht( 'Unable to verify request signature, no "%s" present in '. 'request protocol information.', 'auth.key')); } $signature = idx($meta, 'auth.signature'); if (!strlen($signature)) { throw new Exception( pht( 'Unable to verify request signature, no "%s" present '. 'in request protocol information.', 'auth.signature')); } $prefix = self::SIGNATURE_CONSIGN_1; if (strncmp($signature, $prefix, strlen($prefix)) !== 0) { throw new Exception( pht( 'Unable to verify request signature, signature format is not '. 'known.')); } $signature = substr($signature, strlen($prefix)); $input = self::encodeRequestDataForSignature( $method, $params, $meta); $signature = base64_decode($signature); $trap = new PhutilErrorTrap(); $result = @openssl_verify( $input, $signature, $openssl_public_key); $err = $trap->getErrorsAsString(); $trap->destroy(); if ($result === 1) { // Signature is good. return true; } else if ($result === 0) { // Signature is bad. throw new Exception( pht( 'Request signature verification failed: signature is not correct.')); } else { // Some kind of error. if (strlen($err)) { throw new Exception( pht( 'OpenSSL encountered an error verifying the request signature: %s', $err)); } else { throw new Exception( pht( 'OpenSSL encountered an unknown error verifying the request: %s', $err)); } } } private static function encodeRequestDataForSignature( $method, array $params, array $meta) { unset($meta['auth.signature']); $structure = array( 'method' => $method, 'protocol' => $meta, 'parameters' => $params, ); return self::encodeRawDataForSignature($structure); } public static function encodeRawDataForSignature($data) { $out = array(); if (is_array($data)) { if (phutil_is_natural_list($data)) { $out[] = 'A'; $out[] = count($data); $out[] = ':'; foreach ($data as $value) { $out[] = self::encodeRawDataForSignature($value); } } else { ksort($data); $out[] = 'O'; $out[] = count($data); $out[] = ':'; foreach ($data as $key => $value) { $out[] = self::encodeRawDataForSignature($key); $out[] = self::encodeRawDataForSignature($value); } } } else if (is_string($data)) { $out[] = 'S'; $out[] = strlen($data); $out[] = ':'; $out[] = $data; } else if (is_int($data)) { $out[] = 'I'; $out[] = strlen((string)$data); $out[] = ':'; $out[] = (string)$data; } else if (is_null($data)) { $out[] = 'N'; $out[] = ':'; } else if ($data === true) { $out[] = 'B1:'; } else if ($data === false) { $out[] = 'B0:'; } else { throw new Exception( pht( 'Unexpected data type in request data: %s.', gettype($data))); } return implode('', $out); } } diff --git a/src/conduit/ConduitFuture.php b/src/conduit/ConduitFuture.php index f6c192b6..20c6906a 100644 --- a/src/conduit/ConduitFuture.php +++ b/src/conduit/ConduitFuture.php @@ -1,76 +1,90 @@ client = $client; $this->conduitMethod = $method; return $this; } public function isReady() { if ($this->profilerCallID === null) { $profiler = PhutilServiceProfiler::getInstance(); $this->profilerCallID = $profiler->beginServiceCall( array( 'type' => 'conduit', 'method' => $this->conduitMethod, 'size' => $this->getProxiedFuture()->getHTTPRequestByteLength(), )); } return parent::isReady(); } protected function didReceiveResult($result) { if ($this->profilerCallID !== null) { $profiler = PhutilServiceProfiler::getInstance(); $profiler->endServiceCall( $this->profilerCallID, array()); } list($status, $body, $headers) = $result; if ($status->isError()) { throw $status; } + $capabilities = array(); + foreach ($headers as $header) { + list($name, $value) = $header; + if (!strcasecmp($name, 'X-Conduit-Capabilities')) { + $capabilities = explode(' ', $value); + break; + } + } + + if ($capabilities) { + $this->client->enableCapabilities($capabilities); + } + $raw = $body; $shield = 'for(;;);'; if (!strncmp($raw, $shield, strlen($shield))) { $raw = substr($raw, strlen($shield)); } $data = null; try { $data = phutil_json_decode($raw); } catch (PhutilJSONParserException $ex) { throw new PhutilProxyException( pht( 'Host returned HTTP/200, but invalid JSON data in response to '. 'a Conduit method call.'), $ex); } if ($data['error_code']) { throw new ConduitClientException( $data['error_code'], $data['error_info']); } $result = $data['result']; $result = $this->client->didReceiveResponse( $this->conduitMethod, $result); return $result; } }