Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class CurlEventPublisher implements EventPublisher
private int $_connectTimeout;
private int $_timeout;
private bool $_isWindows;
private ?string $_httpProxy;

/**
* The directory named by the payload_temp_dir option, or null if the option is not set.
Expand Down Expand Up @@ -59,6 +60,7 @@ public function __construct(string $sdkKey, array $options = [])
$this->_connectTimeout = intval($options['connect_timeout']);
$this->_timeout = intval($options['timeout']);
$this->_isWindows = PHP_OS_FAMILY == 'Windows';
$this->_httpProxy = Util::httpProxy($options);
$this->_payloadTempDir = $this->resolvePayloadTempDir($options['payload_temp_dir'] ?? null);
}

Expand Down Expand Up @@ -138,6 +140,9 @@ private function makeCurlRequest(string $payload, ?string $payloadFile = null):
$args = " -X POST";
$args.= " --connect-timeout " . $this->_connectTimeout;
$args.= " --max-time " . $this->_timeout;
if ($this->_httpProxy !== null) {
$args.= " --proxy " . escapeshellarg($this->_httpProxy);
}

foreach ($this->_eventHeaders as $key => $value) {
$args.= " -H " . escapeshellarg("$key: $value");
Expand Down Expand Up @@ -178,13 +183,18 @@ private function makePowershellRequest(string $payloadFile): bool
}

$scheme = $this->_ssl ? "https://" : "http://";
$escapedFile = str_replace("'", "''", $payloadFile);
Comment thread
jsonbailey marked this conversation as resolved.
$escapedUri = str_replace("'", "''", $scheme . $this->_host . ":" . $this->_port . $this->_path . "/bulk");
$args = " Invoke-WebRequest";
$args.= " -Method POST";
$args.= " -UseBasicParsing";
$args.= " -InFile '$payloadFile'";
$args.= " -InFile '$escapedFile'";
$args.= " -H @{" . $headerString . "}";
$args.= " -Uri " . escapeshellarg($scheme . $this->_host . ":" . $this->_port . $this->_path . "/bulk");
$args.= " ; Remove-Item '$payloadFile'";
if ($this->_httpProxy !== null) {
$args.= " -Proxy '" . str_replace("'", "''", $this->_httpProxy) . "'";
}
$args.= " -Uri '$escapedUri'";
$args.= " ; Remove-Item '$escapedFile'";

$cmd = base64_encode(iconv('ISO-8859-1', 'UTF-16LE', $args));
shell_exec("start /B powershell.exe -encodedCommand $cmd > nul 2>&1");
Expand Down
4 changes: 4 additions & 0 deletions src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ public function __construct(string $sdkKey, array $options = [])
'timeout' => $options['timeout'],
'connect_timeout' => $options['connect_timeout']
];
$proxy = Util::httpProxy($options);
if ($proxy !== null) {
$this->_requestOptions['proxy'] = $proxy;
}
}

public function publish(string $payload): bool
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ public function __construct(string $baseUri, string $sdkKey, array $options)
'debug' => $options['debug'] ?? false,
'base_uri' => $baseUri
];
$proxy = Util::httpProxy($options);
if ($proxy !== null) {
$defaults['proxy'] = $proxy;
}

$this->_client = new Client($defaults);
}
Expand Down
21 changes: 21 additions & 0 deletions src/LaunchDarkly/Impl/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,27 @@ public static function logExceptionAtErrorLevel(LoggerInterface $logger, \Throwa
);
}

/**
* Reads the http_proxy option.
*
* A value that is not a non-empty string is ignored, so the proxy environment variables apply.
*
* @return ?string the proxy URL, or null if there is no valid value
*/
public static function httpProxy(array $options): ?string
{
$proxy = $options['http_proxy'] ?? null;
if ($proxy === null || (is_string($proxy) && $proxy !== '')) {
return $proxy;
}

$logger = $options['logger'] ?? null;
if ($logger instanceof LoggerInterface) {
$logger->warning('The http_proxy option is not a non-empty string. The SDK ignores it.');
}
return null;
}

public static function makeNullLogger(): LoggerInterface
{
return new Logger('', [new NullHandler()]);
Expand Down
6 changes: 6 additions & 0 deletions src/LaunchDarkly/Integrations/Curl.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ class Curl
* This implementation forks a process for each event payload. Alternatively, you can use
* {@see \LaunchDarkly\Integrations\Guzzle::eventPublisher()}, which makes synchronous requests.
*
* On Windows, this publisher does not use curl. It sends events with the PowerShell `Invoke-WebRequest`
* command, and it ignores the `curl` option.
*
* @param array $options Configuration settings (can also be passed in the main client configuration):
* - `curl`: command for executing `curl`; defaults to `/usr/bin/env curl`
* - `payload_temp_dir`: directory for the temporary file that holds the event payload. Use `true`
Expand All @@ -34,6 +37,9 @@ class Curl
* with `Argument exceeds the allowed length of N bytes`. On Windows the payload always goes into a
* file, and this option only names the directory; the default there is the system temporary
* directory.
* - `http_proxy`: URL of a web proxy for the requests; see {@see \LaunchDarkly\LDClient::__construct()}.
* On Windows, `Invoke-WebRequest` does not read credentials from the proxy URL. To use a proxy that
* requires authentication on Windows, use {@see \LaunchDarkly\Integrations\Guzzle::eventPublisher()}.
* @return mixed an object to be stored in the `event_publisher` configuration property
*/
public static function eventPublisher(array $options = []): mixed
Expand Down
2 changes: 2 additions & 0 deletions src/LaunchDarkly/Integrations/Guzzle.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Guzzle
* - `cache`: an optional object that implements `Kevinrob\GuzzleCache\Storage\CacheStorageInterface`
* - `connect_timeout`: connection timeout in seconds; defaults to 3
* - `timeout`: read timeout in seconds; defaults to 3
* - `http_proxy`: URL of a web proxy for the requests; see {@see \LaunchDarkly\LDClient::__construct()}
* @return mixed an object to be stored in the `feature_requester` configuration property
*/
public static function featureRequester(array $options = []): mixed
Expand Down Expand Up @@ -54,6 +55,7 @@ public static function featureRequester(array $options = []): mixed
* - `events_uri`: URI of the server that will receive events, if it is `ld-relay` instead of LaunchDarkly
* - `connect_timeout`: connection timeout in seconds; defaults to 3
* - `timeout`: read timeout in seconds; defaults to 3
* - `http_proxy`: URL of a web proxy for the requests; see {@see \LaunchDarkly\LDClient::__construct()}
* @return mixed an object to be stored in the `event_publisher` configuration property
*/
public static function eventPublisher(array $options = []): mixed
Expand Down
6 changes: 6 additions & 0 deletions src/LaunchDarkly/LDClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ class LDClient
* - `events_uri`: Base URI for sending events to LaunchDarkly. Change this if you are forwarding events through a Relay Proxy instance.
* - `timeout`: The maximum length of an HTTP request in seconds. Defaults to 3.
* - `connect_timeout`: The maximum number of seconds to wait while trying to connect to a server. Defaults to 3.
* - `http_proxy`: Use a proxy when the SDK connects to LaunchDarkly. This is the full URL of the proxy, for example
* `http://my-proxy.com:1234` or `http://user:pass@my-proxy.com:1234`. The SDK uses the proxy for HTTP and HTTPS
* targets. This option overrides the `HTTPS_PROXY` and `HTTP_PROXY` environment variables, but only for SDK
* connections. The `NO_PROXY` environment variable still applies, because libcurl reads it. On Windows, the
* default event publisher uses PowerShell, which does not read credentials from the proxy URL. See
* {@see \LaunchDarkly\Integrations\Curl::eventPublisher()}.
* - `cache`: An optional implementation of Guzzle's [CacheStorageInterface](https://github.com/Kevinrob/guzzle-cache-middleware/blob/master/src/Storage/CacheStorageInterface.php).
* Defaults to an in-memory cache.
* - `send_events`: If set to false, disables the sending of events to LaunchDarkly. Defaults to true.
Expand Down
29 changes: 29 additions & 0 deletions tests/Impl/Integrations/CurlEventPublisherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,33 @@ public function testRemovesPayloadFileAfterTheRequest()

$this->assertEmpty(glob($dir . '/ld-*') ?: []);
}

public function testPassesHttpProxyToCurl()
{
$publisher = $this->makePublisher(['http_proxy' => 'http://user:pass@my-proxy:8080']);
$this->assertTrue($publisher->publish(self::PAYLOAD));

$args = $this->awaitRecordedArgs();
$index = array_search('--proxy', $args, true);
$this->assertNotFalse($index);
$this->assertEquals('http://user:pass@my-proxy:8080', $args[$index + 1]);
}

public function testDoesNotPassProxyByDefault()
{
$this->assertTrue($this->makePublisher()->publish(self::PAYLOAD));

$this->assertNotContains('--proxy', $this->awaitRecordedArgs());
}

/**
* @testWith [false]
* [""]
*/
public function testIgnoresInvalidHttpProxy(mixed $proxy)
{
$this->assertTrue($this->makePublisher(['http_proxy' => $proxy])->publish(self::PAYLOAD));

$this->assertNotContains('--proxy', $this->awaitRecordedArgs());
}
}
57 changes: 57 additions & 0 deletions tests/Impl/Integrations/EventPublisherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,63 @@ public function getEventPublisher(): array
];
}

public function getProxiedEventPublisher(): array
{
/** @var LoggerInterface **/
$logger = $this->getMockBuilder(LoggerInterface::class)->getMock();

// The host does not resolve, so the request can only arrive through the proxy.
$config = [
'events_uri' => 'http://not.valid.local',
'timeout' => 3,
'connect_timeout' => 3,
'logger' => $logger,
'http_proxy' => 'http://localhost:8080',
];

return [
[new Integrations\CurlEventPublisher('sdk-key', $config)],
[new Integrations\CurlEventPublisher('sdk-key', array_merge($config, ['payload_temp_dir' => true]))],
[new Integrations\GuzzleEventPublisher('sdk-key', $config)],
];
}

/**
* @dataProvider getProxiedEventPublisher
*/
public function testSendsThroughHttpProxy($publisher)
{
$event = json_encode(["key" => "user-key"]);
$publisher->publish($event);

$requests = [];
$client = new Client();

// Provide time for the curl to execute
$start = time();
while (time() - $start < 5) {
$response = $client->request('GET', 'http://localhost:8080/__admin/requests');
$body = json_decode($response->getBody()->getContents(), true);
$requests = $body['requests'];

if ($requests) {
break;
}
usleep(100);
}

if (!$requests) {
$this->fail("Unable to connect to endpoint within specified timeout");
}

$this->assertCount(1, $requests);

$request = $requests[0]['request'];
$this->assertEquals($event, $request['body']);
$this->assertEquals('/bulk', $request['url']);
$this->assertEquals('not.valid.local', $request['headers']['Host']);
}

/**
* @dataProvider getEventPublisher
*/
Expand Down
43 changes: 43 additions & 0 deletions tests/Impl/Integrations/GuzzleFeatureRequesterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,49 @@ public function testSendsCorrectHeaders(): void
$this->assertEquals('my-instance-id', $headers['X-LaunchDarkly-Instance-Id']);
}

public function testSendsRequestThroughHttpProxy(): void
{
/** @var LoggerInterface **/
$logger = $this->getMockBuilder(LoggerInterface::class)->getMock();

$config = [
'logger' => $logger,
'timeout' => 3,
'connect_timeout' => 3,
'http_proxy' => 'http://localhost:8080',
];

// The host does not resolve, so the request can only arrive through the proxy.
$requester = new GuzzleFeatureRequester('http://not.valid.local', 'sdk-key', $config);
$requester->getFeature("flag-key");

$requests = [];
$client = new Client();

// Provide time for the curl to execute
$start = time();
while (time() - $start < 5) {
$response = $client->request('GET', 'http://localhost:8080/__admin/requests');
$body = json_decode($response->getBody()->getContents(), true);
$requests = $body['requests'];

if ($requests) {
break;
}
usleep(100);
}

if (!$requests) {
$this->fail("Unable to connect to endpoint within specified timeout");
}

$this->assertCount(1, $requests);

$request = $requests[0]['request'];
$this->assertEquals('/sdk/flags/flag-key', $request['url']);
$this->assertEquals('not.valid.local', $request['headers']['Host']);
}

public function wrapperProvider(): array
{
return [
Expand Down
Loading