From c5b0495590e7890abcf718a36776c9c0b2fa188f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 28 Aug 2017 17:22:31 +0200 Subject: [PATCH 1/3] Report proxy connection issues --- src/ProxyConnector.php | 2 ++ tests/FunctionalTest.php | 10 ++++++++++ tests/ProxyConnectorTest.php | 12 ++++++++++++ 3 files changed, 24 insertions(+) diff --git a/src/ProxyConnector.php b/src/ProxyConnector.php index ed27b3d..121de9a 100644 --- a/src/ProxyConnector.php +++ b/src/ProxyConnector.php @@ -189,6 +189,8 @@ public function connect($uri) $stream->write("CONNECT " . $host . ":" . $port . " HTTP/1.1\r\nHost: " . $host . ":" . $port . "\r\n" . $auth . "\r\n"); return $deferred->promise(); + }, function (Exception $e) use ($proxyUri) { + throw new RuntimeException('Unable to connect to proxy (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111, $e); }); } } diff --git a/tests/FunctionalTest.php b/tests/FunctionalTest.php index a8f2c04..0e95724 100644 --- a/tests/FunctionalTest.php +++ b/tests/FunctionalTest.php @@ -28,6 +28,16 @@ public function setUp() $this->dnsConnector = new DnsConnector($this->tcpConnector, $resolver); } + public function testNonListeningSocketRejectsConnection() + { + $proxy = new ProxyConnector('127.0.0.1:9999', $this->dnsConnector); + + $promise = $proxy->connect('google.com:80'); + + $this->setExpectedException('RuntimeException', 'Unable to connect to proxy', SOCKET_ECONNREFUSED); + Block\await($promise, $this->loop, 3.0); + } + public function testPlainGoogleDoesNotAcceptConnectMethod() { $proxy = new ProxyConnector('google.com', $this->dnsConnector); diff --git a/tests/ProxyConnectorTest.php b/tests/ProxyConnectorTest.php index 6f51dea..1cb2019 100644 --- a/tests/ProxyConnectorTest.php +++ b/tests/ProxyConnectorTest.php @@ -162,6 +162,18 @@ public function testRejectsUriWithNonTcpScheme() $promise->then(null, $this->expectCallableOnce()); } + public function testRejectsIfConnectorRejects() + { + $promise = \React\Promise\reject(new \RuntimeException()); + $this->connector->expects($this->once())->method('connect')->willReturn($promise); + + $proxy = new ProxyConnector('proxy.example.com', $this->connector); + + $promise = $proxy->connect('google.com:80'); + + $promise->then(null, $this->expectCallableOnce()); + } + public function testRejectsAndClosesIfStreamWritesNonHttp() { $stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('close', 'write'))->getMock(); From 5db04adbb28a48aa8b3db39a23b8d54a6b4c5974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 28 Aug 2017 17:41:47 +0200 Subject: [PATCH 2/3] Use socket error codes for connection rejections --- src/ProxyConnector.php | 12 ++++++------ tests/AbstractTestCase.php | 14 ++++++++++++++ tests/FunctionalTest.php | 6 +++--- tests/ProxyConnectorTest.php | 8 ++++---- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/ProxyConnector.php b/src/ProxyConnector.php index 121de9a..f4e07f3 100644 --- a/src/ProxyConnector.php +++ b/src/ProxyConnector.php @@ -128,7 +128,7 @@ public function connect($uri) return $this->connector->connect($proxyUri)->then(function (ConnectionInterface $stream) use ($host, $port, $auth) { $deferred = new Deferred(function ($_, $reject) use ($stream) { - $reject(new RuntimeException('Operation canceled while waiting for response from proxy')); + $reject(new RuntimeException('Connection canceled while waiting for response from proxy (ECONNABORTED)', defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103)); $stream->close(); }); @@ -146,14 +146,14 @@ public function connect($uri) try { $response = Psr7\parse_response(substr($buffer, 0, $pos)); } catch (Exception $e) { - $deferred->reject(new RuntimeException('Invalid response received from proxy: ' . $e->getMessage(), 0, $e)); + $deferred->reject(new RuntimeException('Invalid response received from proxy (EBADMSG)', defined('SOCKET_EBADMSG') ? SOCKET_EBADMSG: 71, $e)); $stream->close(); return; } // status must be 2xx if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { - $deferred->reject(new RuntimeException('Proxy rejected with HTTP error code: ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase(), $response->getStatusCode())); + $deferred->reject(new RuntimeException('Proxy refused connection with HTTP error code ' . $response->getStatusCode() . ' (' . $response->getReasonPhrase() . ') (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111)); $stream->close(); return; } @@ -172,18 +172,18 @@ public function connect($uri) // stop buffering when 8 KiB have been read if (isset($buffer[8192])) { - $deferred->reject(new RuntimeException('Proxy must not send more than 8 KiB of headers')); + $deferred->reject(new RuntimeException('Proxy must not send more than 8 KiB of headers (EMSGSIZE)', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90)); $stream->close(); } }; $stream->on('data', $fn); $stream->on('error', function (Exception $e) use ($deferred) { - $deferred->reject(new RuntimeException('Stream error while waiting for response from proxy', 0, $e)); + $deferred->reject(new RuntimeException('Stream error while waiting for response from proxy (EIO)', defined('SOCKET_EIO') ? SOCKET_EIO : 5, $e)); }); $stream->on('close', function () use ($deferred) { - $deferred->reject(new RuntimeException('Connection to proxy lost while waiting for response')); + $deferred->reject(new RuntimeException('Connection to proxy lost while waiting for response (ECONNRESET)', defined('SOCKET_ECONNRESET') ? SOCKET_ECONNRESET : 104)); }); $stream->write("CONNECT " . $host . ":" . $port . " HTTP/1.1\r\nHost: " . $host . ":" . $port . "\r\n" . $auth . "\r\n"); diff --git a/tests/AbstractTestCase.php b/tests/AbstractTestCase.php index 3250568..632b314 100644 --- a/tests/AbstractTestCase.php +++ b/tests/AbstractTestCase.php @@ -37,6 +37,20 @@ protected function expectCallableOnceWith($value) return $mock; } + protected function expectCallableOnceWithExceptionCode($code) + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->callback(function ($e) use ($code) { + return $e->getCode() === $code; + })); + + return $mock; + } + + protected function expectCallableOnceParameter($type) { $mock = $this->createCallableMock(); diff --git a/tests/FunctionalTest.php b/tests/FunctionalTest.php index 0e95724..23273cf 100644 --- a/tests/FunctionalTest.php +++ b/tests/FunctionalTest.php @@ -44,7 +44,7 @@ public function testPlainGoogleDoesNotAcceptConnectMethod() $promise = $proxy->connect('google.com:80'); - $this->setExpectedException('RuntimeException', 'Method Not Allowed', 405); + $this->setExpectedException('RuntimeException', '405 (Method Not Allowed)', SOCKET_ECONNREFUSED); Block\await($promise, $this->loop, 3.0); } @@ -59,7 +59,7 @@ public function testSecureGoogleDoesNotAcceptConnectMethod() $promise = $proxy->connect('google.com:80'); - $this->setExpectedException('RuntimeException', 'Method Not Allowed', 405); + $this->setExpectedException('RuntimeException', '405 (Method Not Allowed)', SOCKET_ECONNREFUSED); Block\await($promise, $this->loop, 3.0); } @@ -69,7 +69,7 @@ public function testSecureGoogleDoesNotAcceptPlainStream() $promise = $proxy->connect('google.com:80'); - $this->setExpectedException('RuntimeException', 'Connection to proxy lost'); + $this->setExpectedException('RuntimeException', 'Connection to proxy lost', SOCKET_ECONNRESET); Block\await($promise, $this->loop, 3.0); } } diff --git a/tests/ProxyConnectorTest.php b/tests/ProxyConnectorTest.php index 1cb2019..a86508b 100644 --- a/tests/ProxyConnectorTest.php +++ b/tests/ProxyConnectorTest.php @@ -188,7 +188,7 @@ public function testRejectsAndClosesIfStreamWritesNonHttp() $stream->expects($this->once())->method('close'); $stream->emit('data', array("invalid\r\n\r\n")); - $promise->then(null, $this->expectCallableOnce()); + $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EBADMSG)); } public function testRejectsAndClosesIfStreamWritesTooMuchData() @@ -205,7 +205,7 @@ public function testRejectsAndClosesIfStreamWritesTooMuchData() $stream->expects($this->once())->method('close'); $stream->emit('data', array(str_repeat('*', 100000))); - $promise->then(null, $this->expectCallableOnce()); + $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EMSGSIZE)); } public function testRejectsAndClosesIfStreamReturnsNonSuccess() @@ -222,7 +222,7 @@ public function testRejectsAndClosesIfStreamReturnsNonSuccess() $stream->expects($this->once())->method('close'); $stream->emit('data', array("HTTP/1.1 403 Not allowed\r\n\r\n")); - $promise->then(null, $this->expectCallableOnce()); + $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNREFUSED)); } public function testResolvesIfStreamReturnsSuccess() @@ -280,6 +280,6 @@ public function testCancelPromiseWillCloseOpenConnectionAndReject() $promise->cancel(); - $promise->then(null, $this->expectCallableOnce()); + $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNABORTED)); } } From 7258a76f492357874d0af1fe1f50a08209f61174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 28 Aug 2017 17:49:52 +0200 Subject: [PATCH 3/3] Use EACCES error code if proxy uses 407 (Proxy Authentication Required) --- README.md | 3 ++- src/ProxyConnector.php | 11 +++++++---- tests/ProxyConnectorTest.php | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b7b89dd..20f8a5f 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,8 @@ $proxy = new ProxyConnector( connection attempt. If the authentication details are missing or not accepted by the remote HTTP proxy server, it is expected to reject each connection attempt with a - `407` (Proxy Authentication Required) response status code. + `407` (Proxy Authentication Required) response status code and an exception + error code of `SOCKET_EACCES` (13). #### Advanced secure proxy connections diff --git a/src/ProxyConnector.php b/src/ProxyConnector.php index f4e07f3..c7801f8 100644 --- a/src/ProxyConnector.php +++ b/src/ProxyConnector.php @@ -151,11 +151,14 @@ public function connect($uri) return; } - // status must be 2xx - if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { + if ($response->getStatusCode() === 407) { + // map status code 407 (Proxy Authentication Required) to EACCES + $deferred->reject(new RuntimeException('Proxy denied connection due to invalid authentication ' . $response->getStatusCode() . ' (' . $response->getReasonPhrase() . ') (EACCES)', defined('SOCKET_EACCES') ? SOCKET_EACCES : 13)); + return $stream->close(); + } elseif ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { + // map non-2xx status code to ECONNREFUSED $deferred->reject(new RuntimeException('Proxy refused connection with HTTP error code ' . $response->getStatusCode() . ' (' . $response->getReasonPhrase() . ') (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111)); - $stream->close(); - return; + return $stream->close(); } // all okay, resolve with stream instance diff --git a/tests/ProxyConnectorTest.php b/tests/ProxyConnectorTest.php index a86508b..6b11828 100644 --- a/tests/ProxyConnectorTest.php +++ b/tests/ProxyConnectorTest.php @@ -208,6 +208,23 @@ public function testRejectsAndClosesIfStreamWritesTooMuchData() $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EMSGSIZE)); } + public function testRejectsAndClosesIfStreamReturnsProyAuthenticationRequired() + { + $stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('close', 'write'))->getMock(); + + $promise = \React\Promise\resolve($stream); + $this->connector->expects($this->once())->method('connect')->willReturn($promise); + + $proxy = new ProxyConnector('proxy.example.com', $this->connector); + + $promise = $proxy->connect('google.com:80'); + + $stream->expects($this->once())->method('close'); + $stream->emit('data', array("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n")); + + $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EACCES)); + } + public function testRejectsAndClosesIfStreamReturnsNonSuccess() { $stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('close', 'write'))->getMock();