diff --git a/README.md b/README.md index aea8655d..e46f47e9 100644 --- a/README.md +++ b/README.md @@ -1061,7 +1061,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); @@ -1094,6 +1094,17 @@ $connector->connect('google.com:80')->then(function (React\Socket\ConnectionInte Internally, the `tcp://` and `tls://` connectors will always be wrapped by `TimeoutConnector`, unless you disable timeouts like in the above example. +> Internally the `HappyEyeBallsConnector` has replaced the `DnsConnector` as default + resolving connector. It is still available as `Connector` has a new option, namely + `happy_eyeballs`, to control which of the two will be used. By default it's `true` + and will use `HappyEyeBallsConnector`, when set to `false` `DnsConnector` is used. + We only recommend doing so when there are any backwards compatible issues on older + systems only supporting IPv4. The `HappyEyeBallsConnector` implements most of + RFC6555 and RFC8305 and will use concurrency to connect to the remote host by + attempting to connect over both IPv4 and IPv6 with a priority for IPv6 when + available. Which ever connection attempt succeeds first will be used, the rest + connection attempts will be canceled. + ### Advanced client usage #### TcpConnector diff --git a/composer.json b/composer.json index 6a062273..f68377ed 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ }, "require-dev": { "clue/block-react": "^1.2", - "phpunit/phpunit": "^6.4 || ^5.7 || ^4.8.35" + "phpunit/phpunit": "^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..06b6c4ef 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' => \PHP_VERSION_ID < 70000 ? false : 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/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..3e45c150 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,174 @@ 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) { + // IPv4 not supported on this system + $this->assertFalse($this->ipv4()); + return; + } + + $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) { + // IPv6 not supported on this system + $this->assertFalse($this->ipv6()); + return; + } + + $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); + } + + /** + * @test + * @group internet + * + * @expectedException \RuntimeException + * @expectedExceptionMessageRegExp /Connection to ipv6.tlund.se:80 failed/ + */ + public function tryingToConnectToAnIPv6OnlyHostWithOutHappyEyeBallsShouldResultInFailure() + { + $loop = Factory::create(); + + $connector = new Connector($loop, array('happy_eyeballs' => false)); + + Block\await($this->request('ipv6.tlund.se', $connector), $loop, self::TIMEOUT); + } + + /** + * @test + * @group internet + * + * @expectedException \RuntimeException + * @expectedExceptionMessageRegExp /Connection to tcp:\/\/193.15.228.195:80 failed:/ + */ + public function connectingDirectlyToAnIPv4AddressShouldFailWhenIPv4IsntAvailable() + { + if ($this->ipv4() === true) { + // IPv4 supported on this system + throw new \RuntimeException('Connection to tcp://193.15.228.195:80 failed:'); + } + + $loop = Factory::create(); + + $connector = new Connector($loop); + + $host = current(dns_get_record('ipv4.tlund.se', DNS_A)); + $host = $host['ip']; + Block\await($this->request($host, $connector), $loop, self::TIMEOUT); + } + + /** + * @test + * @group internet + * + * @expectedException \RuntimeException + * @expectedExceptionMessageRegExp /Connection to tcp:\/\/\[2a00:801:f::195\]:80 failed:/ + */ + public function connectingDirectlyToAnIPv6AddressShouldFailWhenIPv6IsntAvailable() + { + if ($this->ipv6() === true) { + // IPv6 supported on this system + throw new \RuntimeException('Connection to tcp://[2a00:801:f::195]:80 failed:'); + } + + $loop = Factory::create(); + + $connector = new Connector($loop); + + $host = current(dns_get_record('ipv6.tlund.se', DNS_AAAA)); + $host = $host['ipv6']; + $host = '[' . $host . ']'; + $ip = Block\await($this->request($host, $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; + } }