mirror of
https://we.phorge.it/source/arcanist.git
synced 2024-11-14 10:52:40 +01:00
377ed2ed8d
Summary: Ref T13507. For various messy reasons we can't blindly assume the server supports "gzip" -- but if the server tells us it does, we're on firmer ground. If the server returns an "X-Conduit-Capabilities: gzip" header and we have compression support locally, compress subsequent requests. This restores D21073, which was reverted by D21076. Test Plan: With a gzip-asserting server, added debugging code and ran various "arc" commands. Saw the 2nd..Nth calls hit compression code. Maniphest Tasks: T13507 Differential Revision: https://secure.phabricator.com/D21119
417 lines
10 KiB
PHP
417 lines
10 KiB
PHP
<?php
|
|
|
|
final class ConduitClient extends Phobject {
|
|
|
|
private $uri;
|
|
private $host;
|
|
private $connectionID;
|
|
private $sessionKey;
|
|
private $timeout = 300.0;
|
|
private $username;
|
|
private $password;
|
|
private $publicKey;
|
|
private $privateKey;
|
|
private $conduitToken;
|
|
private $oauthToken;
|
|
private $capabilities = array();
|
|
|
|
const AUTH_ASYMMETRIC = 'asymmetric';
|
|
|
|
const SIGNATURE_CONSIGN_1 = 'Consign1.0/';
|
|
|
|
public function getConnectionID() {
|
|
return $this->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);
|
|
$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);
|
|
}
|
|
|
|
}
|