diff --git a/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php b/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php index 75f95149..7474006f 100644 --- a/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php +++ b/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php @@ -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. @@ -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); } @@ -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"); @@ -178,13 +183,18 @@ private function makePowershellRequest(string $payloadFile): bool } $scheme = $this->_ssl ? "https://" : "http://"; + $escapedFile = str_replace("'", "''", $payloadFile); + $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"); diff --git a/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php b/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php index fb07b581..ee0283b8 100644 --- a/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php +++ b/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php @@ -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 diff --git a/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php b/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php index e012d582..5ddffa51 100644 --- a/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php +++ b/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php @@ -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); } diff --git a/src/LaunchDarkly/Impl/Util.php b/src/LaunchDarkly/Impl/Util.php index 0cfcefa0..b0a3b0f8 100644 --- a/src/LaunchDarkly/Impl/Util.php +++ b/src/LaunchDarkly/Impl/Util.php @@ -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()]); diff --git a/src/LaunchDarkly/Integrations/Curl.php b/src/LaunchDarkly/Integrations/Curl.php index 3d81480b..d2784715 100644 --- a/src/LaunchDarkly/Integrations/Curl.php +++ b/src/LaunchDarkly/Integrations/Curl.php @@ -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` @@ -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 diff --git a/src/LaunchDarkly/Integrations/Guzzle.php b/src/LaunchDarkly/Integrations/Guzzle.php index ba712832..0f8f229c 100644 --- a/src/LaunchDarkly/Integrations/Guzzle.php +++ b/src/LaunchDarkly/Integrations/Guzzle.php @@ -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 @@ -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 diff --git a/src/LaunchDarkly/LDClient.php b/src/LaunchDarkly/LDClient.php index d545cebb..30ff149f 100644 --- a/src/LaunchDarkly/LDClient.php +++ b/src/LaunchDarkly/LDClient.php @@ -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. diff --git a/tests/Impl/Integrations/CurlEventPublisherTest.php b/tests/Impl/Integrations/CurlEventPublisherTest.php index 1410ad23..069f30ce 100644 --- a/tests/Impl/Integrations/CurlEventPublisherTest.php +++ b/tests/Impl/Integrations/CurlEventPublisherTest.php @@ -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()); + } } diff --git a/tests/Impl/Integrations/EventPublisherTest.php b/tests/Impl/Integrations/EventPublisherTest.php index b04b1428..5de10e49 100644 --- a/tests/Impl/Integrations/EventPublisherTest.php +++ b/tests/Impl/Integrations/EventPublisherTest.php @@ -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 */ diff --git a/tests/Impl/Integrations/GuzzleFeatureRequesterTest.php b/tests/Impl/Integrations/GuzzleFeatureRequesterTest.php index da98eee3..682f1c58 100644 --- a/tests/Impl/Integrations/GuzzleFeatureRequesterTest.php +++ b/tests/Impl/Integrations/GuzzleFeatureRequesterTest.php @@ -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 [