Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make happy eyeballs algorithm (IPv6) the default, add new happy_eyeballs option to Connector #224

Merged
merged 3 commits into from
Mar 8, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
7 changes: 6 additions & 1 deletion src/Connector.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public function __construct(LoopInterface $loop, array $options = array())

'dns' => true,
'timeout' => true,
'happy_eyeballs' => true,
);

if ($options['timeout'] === true) {
Expand Down Expand Up @@ -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) {
Expand Down
92 changes: 35 additions & 57 deletions src/HappyEyeBallsConnectionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
6 changes: 4 additions & 2 deletions tests/ConnectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
107 changes: 106 additions & 1 deletion tests/FunctionalConnectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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;
}
}
47 changes: 47 additions & 0 deletions tests/HappyEyeBallsConnectionBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace React\Tests\Socket;

use React\Promise\Promise;
use React\Socket\HappyEyeBallsConnectionBuilder;

class HappyEyeBallsConnectionBuilderTest extends TestCase
{
public function testAttemptConnectionWillConnectViaConnectorToGivenIpWithPortAndHostnameFromUriParts()
{
$loop = $this->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');
}
}
Loading