diff --git a/README.md b/README.md index dcabda8a..1e7016c1 100644 --- a/README.md +++ b/README.md @@ -927,6 +927,22 @@ also shares all of their features and implementation details. If you want to typehint in your higher-level protocol implementation, you SHOULD use the generic [`ConnectorInterface`](#connectorinterface) instead. +As of `v1.4.0`, the `Connector` class defaults to using the +[happy eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to +automatically connect over IPv4 or IPv6 when a hostname is given. +This automatically attempts to connect using both IPv4 and IPv6 at the same time +(preferring IPv6), thus avoiding the usual problems faced by users with imperfect +IPv6 connections or setups. +If you want to revert to the old behavior of only doing an IPv4 lookup and +only attempt a single IPv4 connection, you can set up the `Connector` like this: + +```php +$connector = new React\Socket\Connector($loop, array( + 'happy_eyeballs' => false +)); +``` + +Similarly, you can also affect the default DNS behavior as follows. The `Connector` class will try to detect your system DNS settings (and uses Google's public DNS server `8.8.8.8` as a fallback if unable to determine your system settings) to resolve all public hostnames into underlying IP addresses by @@ -977,7 +993,7 @@ $connector->connect('localhost:80')->then(function (React\Socket\ConnectionInter ``` By default, the `tcp://` and `tls://` URI schemes will use timeout value that -repects your `default_socket_timeout` ini setting (which defaults to 60s). +respects your `default_socket_timeout` ini setting (which defaults to 60s). If you want a custom timeout value, you can simply pass this like this: ```php @@ -1061,7 +1077,7 @@ pass an instance implementing the `ConnectorInterface` like this: ```php $dnsResolverFactory = new React\Dns\Resolver\Factory(); $resolver = $dnsResolverFactory->createCached('127.0.1.1', $loop); -$tcp = new React\Socket\DnsConnector(new React\Socket\TcpConnector($loop), $resolver); +$tcp = new React\Socket\HappyEyeBallsConnector($loop, new React\Socket\TcpConnector($loop), $resolver); $tls = new React\Socket\SecureConnector($tcp, $loop); diff --git a/composer.json b/composer.json index baa683d5..355c62e1 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ }, "require-dev": { "clue/block-react": "^1.2", - "phpunit/phpunit": "^7.5 || ^6.4 || ^5.7 || ^4.8.35" + "phpunit/phpunit": "^7.5 || ^6.4 || ^5.7 || ^4.8.35", + "react/promise-stream": "^1.2" }, "autoload": { "psr-4": { diff --git a/src/Connector.php b/src/Connector.php index 2a4f19a2..578d10b8 100644 --- a/src/Connector.php +++ b/src/Connector.php @@ -36,6 +36,7 @@ public function __construct(LoopInterface $loop, array $options = array()) 'dns' => true, 'timeout' => true, + 'happy_eyeballs' => true, ); if ($options['timeout'] === true) { @@ -70,7 +71,11 @@ public function __construct(LoopInterface $loop, array $options = array()) ); } - $tcp = new DnsConnector($tcp, $resolver); + if ($options['happy_eyeballs'] === true) { + $tcp = new HappyEyeBallsConnector($loop, $tcp, $resolver); + } else { + $tcp = new DnsConnector($tcp, $resolver); + } } if ($options['tcp'] !== false) { diff --git a/src/HappyEyeBallsConnectionBuilder.php b/src/HappyEyeBallsConnectionBuilder.php index a8553a58..fa10224c 100644 --- a/src/HappyEyeBallsConnectionBuilder.php +++ b/src/HappyEyeBallsConnectionBuilder.php @@ -190,71 +190,49 @@ public function check($resolve, $reject) */ public function attemptConnection($ip) { - $promise = null; - $that = $this; - - return new Promise\Promise( - function ($resolve, $reject) use (&$promise, $that, $ip) { - $uri = ''; + $uri = ''; - // prepend original scheme if known - if (isset($that->parts['scheme'])) { - $uri .= $that->parts['scheme'] . '://'; - } + // prepend original scheme if known + if (isset($this->parts['scheme'])) { + $uri .= $this->parts['scheme'] . '://'; + } - if (\strpos($ip, ':') !== false) { - // enclose IPv6 addresses in square brackets before appending port - $uri .= '[' . $ip . ']'; - } else { - $uri .= $ip; - } + if (\strpos($ip, ':') !== false) { + // enclose IPv6 addresses in square brackets before appending port + $uri .= '[' . $ip . ']'; + } else { + $uri .= $ip; + } - // append original port if known - if (isset($that->parts['port'])) { - $uri .= ':' . $that->parts['port']; - } + // append original port if known + if (isset($this->parts['port'])) { + $uri .= ':' . $this->parts['port']; + } - // append orignal path if known - if (isset($that->parts['path'])) { - $uri .= $that->parts['path']; - } + // append orignal path if known + if (isset($this->parts['path'])) { + $uri .= $this->parts['path']; + } - // append original query if known - if (isset($that->parts['query'])) { - $uri .= '?' . $that->parts['query']; - } + // append original query if known + if (isset($this->parts['query'])) { + $uri .= '?' . $this->parts['query']; + } - // append original hostname as query if resolved via DNS and if - // destination URI does not contain "hostname" query param already - $args = array(); - \parse_str(isset($that->parts['query']) ? $that->parts['query'] : '', $args); - if ($that->host !== $ip && !isset($args['hostname'])) { - $uri .= (isset($that->parts['query']) ? '&' : '?') . 'hostname=' . \rawurlencode($that->host); - } + // append original hostname as query if resolved via DNS and if + // destination URI does not contain "hostname" query param already + $args = array(); + \parse_str(isset($this->parts['query']) ? $this->parts['query'] : '', $args); + if ($this->host !== $ip && !isset($args['hostname'])) { + $uri .= (isset($this->parts['query']) ? '&' : '?') . 'hostname=' . \rawurlencode($this->host); + } - // append original fragment if known - if (isset($that->parts['fragment'])) { - $uri .= '#' . $that->parts['fragment']; - } + // append original fragment if known + if (isset($this->parts['fragment'])) { + $uri .= '#' . $this->parts['fragment']; + } - $promise = $that->connector->connect($uri); - $promise->then($resolve, $reject); - }, - function ($_, $reject) use (&$promise, $that) { - // cancellation should reject connection attempt - // (try to) cancel pending connection attempt - $reject(new \RuntimeException('Connection to ' . $that->uri . ' cancelled during connection attempt')); - - if ($promise instanceof CancellablePromiseInterface) { - // overwrite callback arguments for PHP7+ only, so they do not show - // up in the Exception trace and do not cause a possible cyclic reference. - $_ = $reject = null; - - $promise->cancel(); - $promise = null; - } - } - ); + return $this->connector->connect($uri); } /** diff --git a/tests/ConnectorTest.php b/tests/ConnectorTest.php index 14bba5e5..a8ffc0be 100644 --- a/tests/ConnectorTest.php +++ b/tests/ConnectorTest.php @@ -100,7 +100,8 @@ public function testConnectorUsesGivenResolverInstance() $resolver->expects($this->once())->method('resolve')->with('google.com')->willReturn($promise); $connector = new Connector($loop, array( - 'dns' => $resolver + 'dns' => $resolver, + 'happy_eyeballs' => false, )); $connector->connect('google.com:80'); @@ -120,7 +121,8 @@ public function testConnectorUsesResolvedHostnameIfDnsIsUsed() $connector = new Connector($loop, array( 'tcp' => $tcp, - 'dns' => $resolver + 'dns' => $resolver, + 'happy_eyeballs' => false, )); $connector->connect('tcp://google.com:80'); diff --git a/tests/FunctionalConnectorTest.php b/tests/FunctionalConnectorTest.php index 6611352a..57ef8d5b 100644 --- a/tests/FunctionalConnectorTest.php +++ b/tests/FunctionalConnectorTest.php @@ -4,12 +4,17 @@ use Clue\React\Block; use React\EventLoop\Factory; +use React\Socket\ConnectionInterface; use React\Socket\Connector; +use React\Socket\ConnectorInterface; use React\Socket\TcpServer; class FunctionalConnectorTest extends TestCase { - const TIMEOUT = 1.0; + const TIMEOUT = 30.0; + + private $ipv4; + private $ipv6; /** @test */ public function connectionToTcpServerShouldSucceedWithLocalhost() @@ -29,4 +34,104 @@ public function connectionToTcpServerShouldSucceedWithLocalhost() $connection->close(); $server->close(); } + + /** + * @test + * @group internet + */ + public function connectionToRemoteTCP4n6ServerShouldResultInOurIP() + { + $loop = Factory::create(); + + $connector = new Connector($loop, array('happy_eyeballs' => true)); + + $ip = Block\await($this->request('dual.tlund.se', $connector), $loop, self::TIMEOUT); + + $this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6), $ip); + } + + /** + * @test + * @group internet + */ + public function connectionToRemoteTCP4ServerShouldResultInOurIP() + { + if ($this->ipv4() === false) { + $this->markTestSkipped('IPv4 connection not supported on this system'); + } + + $loop = Factory::create(); + + $connector = new Connector($loop, array('happy_eyeballs' => true)); + + $ip = Block\await($this->request('ipv4.tlund.se', $connector), $loop, self::TIMEOUT); + + $this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4), $ip); + $this->assertFalse(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip); + } + + /** + * @test + * @group internet + */ + public function connectionToRemoteTCP6ServerShouldResultInOurIP() + { + if ($this->ipv6() === false) { + $this->markTestSkipped('IPv6 connection not supported on this system'); + } + + $loop = Factory::create(); + + $connector = new Connector($loop, array('happy_eyeballs' => true)); + + $ip = Block\await($this->request('ipv6.tlund.se', $connector), $loop, self::TIMEOUT); + + $this->assertFalse(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4), $ip); + $this->assertSame($ip, filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6), $ip); + } + + /** + * @internal + */ + public function parseIpFromPage($body) + { + $ex = explode('title="Look up on bgp.he.net">', $body); + $ex = explode('<', $ex[1]); + + return $ex[0]; + } + + private function request($host, ConnectorInterface $connector) + { + $that = $this; + return $connector->connect($host . ':80')->then(function (ConnectionInterface $connection) use ($host) { + $connection->write("GET / HTTP/1.1\r\nHost: " . $host . "\r\n\r\n"); + + return \React\Promise\Stream\buffer($connection); + })->then(function ($response) use ($that) { + return $that->parseIpFromPage($response); + }); + } + + private function ipv4() + { + if ($this->ipv4 !== null) { + return $this->ipv4; + } + + $this->ipv4 = !!@file_get_contents('http://ipv4.tlund.se/'); + + return $this->ipv4; + } + + private function ipv6() + { + if ($this->ipv6 !== null) { + return $this->ipv6; + } + + $this->ipv6 = !!@file_get_contents('http://ipv6.tlund.se/'); + + return $this->ipv6; + } } diff --git a/tests/HappyEyeBallsConnectionBuilderTest.php b/tests/HappyEyeBallsConnectionBuilderTest.php new file mode 100644 index 00000000..4be5e7b5 --- /dev/null +++ b/tests/HappyEyeBallsConnectionBuilderTest.php @@ -0,0 +1,47 @@ +getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('tcp://10.1.1.1:80?hostname=reactphp.org')->willReturn(new Promise(function () { })); + + $resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock(); + $resolver->expects($this->never())->method('resolveAll'); + + $uri = 'tcp://reactphp.org:80'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->attemptConnection('10.1.1.1'); + } + + public function testAttemptConnectionWillConnectViaConnectorToGivenIpv6WithAllUriParts() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $connector = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $connector->expects($this->once())->method('connect')->with('tcp://[::1]:80/path?test=yes&hostname=reactphp.org#start')->willReturn(new Promise(function () { })); + + $resolver = $this->getMockBuilder('React\Dns\Resolver\ResolverInterface')->getMock(); + $resolver->expects($this->never())->method('resolveAll'); + + $uri = 'tcp://reactphp.org:80/path?test=yes#start'; + $host = 'reactphp.org'; + $parts = parse_url($uri); + + $builder = new HappyEyeBallsConnectionBuilder($loop, $connector, $resolver, $uri, $host, $parts); + + $builder->attemptConnection('::1'); + } +} diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index d376f3dc..f2913809 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -203,7 +203,7 @@ function ($e) use (&$wait) { } ); - // run loop for short period to ensure we detect connection timeout error + // run loop for short period to ensure we detect a connection timeout error Block\sleep(0.01, $loop); if ($wait) { Block\sleep(0.2, $loop); @@ -236,7 +236,7 @@ function ($e) use (&$wait) { } ); - // run loop for short period to ensure we detect connection timeout error + // run loop for short period to ensure we detect a connection timeout error Block\sleep(0.01, $loop); if ($wait) { Block\sleep(0.2, $loop); @@ -269,12 +269,15 @@ function ($e) use (&$wait) { } ); - // run loop for short period to ensure we detect DNS error + // run loop for short period to ensure we detect a DNS error Block\sleep(0.01, $loop); if ($wait) { Block\sleep(0.2, $loop); if ($wait) { - $this->fail('Connection attempt did not fail'); + Block\sleep(2.0, $loop); + if ($wait) { + $this->fail('Connection attempt did not fail'); + } } } unset($promise); @@ -309,12 +312,15 @@ function ($e) use (&$wait) { } ); - // run loop for short period to ensure we detect DNS error + // run loop for short period to ensure we detect a TLS error Block\sleep(0.1, $loop); if ($wait) { Block\sleep(0.4, $loop); if ($wait) { - $this->fail('Connection attempt did not fail'); + Block\sleep(self::TIMEOUT - 0.5, $loop); + if ($wait) { + $this->fail('Connection attempt did not fail'); + } } } unset($promise);