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 ed27b3d..c7801f8 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,16 +146,19 @@ 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())); - $stream->close(); - return; + 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)); + return $stream->close(); } // all okay, resolve with stream instance @@ -172,23 +175,25 @@ 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"); 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/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 a8f2c04..23273cf 100644 --- a/tests/FunctionalTest.php +++ b/tests/FunctionalTest.php @@ -28,13 +28,23 @@ 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); $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); } @@ -49,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); } @@ -59,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 6f51dea..6b11828 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(); @@ -176,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() @@ -193,7 +205,24 @@ 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 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() @@ -210,7 +239,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() @@ -268,6 +297,6 @@ public function testCancelPromiseWillCloseOpenConnectionAndReject() $promise->cancel(); - $promise->then(null, $this->expectCallableOnce()); + $promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNABORTED)); } }