diff --git a/README.md b/README.md index 31e155ae..5d76b0c5 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,28 @@ -# Socket Component +# Socket [![Build Status](https://secure.travis-ci.org/reactphp/socket.png?branch=master)](http://travis-ci.org/reactphp/socket) -Async, streaming plaintext TCP/IP and secure TLS socket server for React PHP +Async, streaming plaintext TCP/IP and secure TLS socket server and client +connections for [ReactPHP](https://reactphp.org/) -The socket component provides a more usable interface for a socket-layer -server based on the [`EventLoop`](https://github.com/reactphp/event-loop) +The socket library provides re-usable interfaces for a socket-layer +server and client based on the [`EventLoop`](https://github.com/reactphp/event-loop) and [`Stream`](https://github.com/reactphp/stream) components. +Its server component allows you to build networking servers that accept incoming +connections from networking clients (such as an HTTP server). +Its client component allows you to build networking clients that establish +outgoing connections to networking servers (such as an HTTP or database client). +This library provides async, streaming means for all of this, so you can +handle multiple concurrent connections without blocking. **Table of Contents** * [Quickstart example](#quickstart-example) -* [Usage](#usage) +* [Connection usage](#connection-usage) + * [ConnectionInterface](#connectioninterface) + * [getRemoteAddress()](#getremoteaddress) + * [getLocalAddress()](#getlocaladdress) +* [Server usage](#server-usage) * [ServerInterface](#serverinterface) * [connection event](#connection-event) * [error event](#error-event) @@ -23,9 +34,16 @@ and [`Stream`](https://github.com/reactphp/stream) components. * [SecureServer](#secureserver) * [LimitingServer](#limitingserver) * [getConnections()](#getconnections) - * [ConnectionInterface](#connectioninterface) - * [getRemoteAddress()](#getremoteaddress) - * [getLocalAddress()](#getlocaladdress) +* [Client usage](#client-usage) + * [ConnectorInterface](#connectorinterface) + * [connect()](#connect) + * [Connector](#connector) + * [Advanced client usage](#advanced-client-usage) + * [TcpConnector](#tcpconnector) + * [DnsConnector](#dnsconnector) + * [SecureConnector](#secureconnector) + * [TimeoutConnector](#timeoutconnector) + * [UnixConnector](#unixconnector) * [Install](#install) * [Tests](#tests) * [License](#license) @@ -54,22 +72,116 @@ $loop->run(); See also the [examples](examples). Here's a client that outputs the output of said server and then attempts to -send it a string. -For anything more complex, consider using the -[`SocketClient`](https://github.com/reactphp/socket-client) component instead. +send it a string: ```php $loop = React\EventLoop\Factory::create(); +$connector = new React\Socket\Connector($loop); -$client = stream_socket_client('tcp://127.0.0.1:8080'); -$conn = new React\Stream\Stream($client, $loop); -$conn->pipe(new React\Stream\Stream(STDOUT, $loop)); -$conn->write("Hello World!\n"); +$connector->connect('127.0.0.1:8080')->then(function (ConnectionInterface $conn) use ($loop) { + $conn->pipe(new React\Stream\WritableResourceStream(STDOUT, $loop)); + $conn->write("Hello World!\n"); +}); $loop->run(); ``` -## Usage +## Connection usage + +### ConnectionInterface + +The `ConnectionInterface` is used to represent any incoming and outgoing +connection, such as a normal TCP/IP connection. + +An incoming or outgoing connection is a duplex stream (both readable and +writable) that implements React's +[`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface). +It contains additional properties for the local and remote address (client IP) +where this connection has been established to/from. + +Most commonly, instances implementing this `ConnectionInterface` are emitted +by all classes implementing the [`ServerInterface`](#serverinterface) and +used by all classes implementing the [`ConnectorInterface`](#connectorinterface). + +Because the `ConnectionInterface` implements the underlying +[`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface) +you can use any of its events and methods as usual: + +```php +$connection->on('data', function ($chunk) { + echo $chunk; +}); + +$connection->on('end', function () { + echo 'ended'; +}); + +$connection->on('error', function (Exception $e) { + echo 'error: ' . $e->getMessage(); +}); + +$connection->on('close', function () { + echo 'closed'; +}); + +$connection->write($data); +$connection->end($data = null); +$connection->close(); +// … +``` + +For more details, see the +[`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface). + +#### getRemoteAddress() + +The `getRemoteAddress(): ?string` method returns the full remote address +(client IP and port) where this connection has been established with. + +```php +$address = $connection->getRemoteAddress(); +echo 'Connection with ' . $address . PHP_EOL; +``` + +If the remote address can not be determined or is unknown at this time (such as +after the connection has been closed), it MAY return a `NULL` value instead. + +Otherwise, it will return the full remote address as a string value. +If this is a TCP/IP based connection and you only want the remote IP, you may +use something like this: + +```php +$address = $connection->getRemoteAddress(); +$ip = trim(parse_url('tcp://' . $address, PHP_URL_HOST), '[]'); +echo 'Connection with ' . $ip . PHP_EOL; +``` + +#### getLocalAddress() + +The `getLocalAddress(): ?string` method returns the full local address +(client IP and port) where this connection has been established with. + +```php +$address = $connection->getLocalAddress(); +echo 'Connection with ' . $address . PHP_EOL; +``` + +If the local address can not be determined or is unknown at this time (such as +after the connection has been closed), it MAY return a `NULL` value instead. + +Otherwise, it will return the full local address as a string value. + +This method complements the [`getRemoteAddress()`](#getremoteaddress) method, +so they should not be confused. +If your `Server` instance is listening on multiple interfaces (e.g. using +the address `0.0.0.0`), you can use this method to find out which interface +actually accepted this connection (such as a public or local interface). + +If your system has multiple interfaces (e.g. a WAN and a LAN interface), +you can use this method to find out which interface was actually +used for this connection. + +## Server usage ### ServerInterface @@ -458,95 +570,491 @@ foreach ($server->getConnection() as $connection) { } ``` -### ConnectionInterface +## Client usage -The `ConnectionInterface` is used to represent any incoming connection. +### ConnectorInterface -An incoming connection is a duplex stream (both readable and writable) that -implements React's -[`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface). -It contains additional properties for the local and remote address (client IP) -where this connection has been established from. +The `ConnectorInterface` is responsible for providing an interface for +establishing streaming connections, such as a normal TCP/IP connection. -> Note that this interface is only to be used to represent the server-side end -of an incoming connection. -It MUST NOT be used to represent an outgoing connection in a client-side context. -If you want to establish an outgoing connection, -use the [`SocketClient`](https://github.com/reactphp/socket-client) component instead. +This is the main interface defined in this package and it is used throughout +React's vast ecosystem. -Because the `ConnectionInterface` implements the underlying -[`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface) -you can use any of its events and methods as usual: +Most higher-level components (such as HTTP, database or other networking +service clients) accept an instance implementing this interface to create their +TCP/IP connection to the underlying networking service. +This is usually done via dependency injection, so it's fairly simple to actually +swap this implementation against any other implementation of this interface. + +The interface only offers a single method: + +#### connect() + +The `connect(string $uri): PromiseInterface` method +can be used to create a streaming connection to the given remote address. + +It returns a [Promise](https://github.com/reactphp/promise) which either +fulfills with a stream implementing [`ConnectionInterface`](#connectioninterface) +on success or rejects with an `Exception` if the connection is not successful: ```php -$connection->on('data', function ($chunk) { - echo $chunk; +$connector->connect('google.com:443')->then( + function (ConnectionInterface $connection) { + // connection successfully established + }, + function (Exception $error) { + // failed to connect due to $error + } +); +``` + +See also [`ConnectionInterface`](#connectioninterface) for more details. + +The returned Promise MUST be implemented in such a way that it can be +cancelled when it is still pending. Cancelling a pending promise MUST +reject its value with an `Exception`. It SHOULD clean up any underlying +resources and references as applicable: + +```php +$promise = $connector->connect($uri); + +$promise->cancel(); +``` + +### Connector + +The `Connector` class is the main class in this package that implements the +[`ConnectorInterface`](#connectorinterface) and allows you to create streaming connections. + +You can use this connector to create any kind of streaming connections, such +as plaintext TCP/IP, secure TLS or local Unix connection streams. + +It binds to the main event loop and can be used like this: + +```php +$loop = React\EventLoop\Factory::create(); +$connector = new Connector($loop); + +$connector->connect($uri)->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); }); -$connection->on('end', function () { - echo 'ended'; +$loop->run(); +``` + +In order to create a plaintext TCP/IP connection, you can simply pass a host +and port combination like this: + +```php +$connector->connect('www.google.com:80')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); }); +``` -$connection->on('error', function (Exception $e) { - echo 'error: ' . $e->getMessage(); +> If you do no specify a URI scheme in the destination URI, it will assume + `tcp://` as a default and establish a plaintext TCP/IP connection. + Note that TCP/IP connections require a host and port part in the destination + URI like above, all other URI components are optional. + +In order to create a secure TLS connection, you can use the `tls://` URI scheme +like this: + +```php +$connector->connect('tls://www.google.com:443')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); }); +``` -$connection->on('close', function () { - echo 'closed'; +In order to create a local Unix domain socket connection, you can use the +`unix://` URI scheme like this: + +```php +$connector->connect('unix:///tmp/demo.sock')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); }); +``` -$connection->write($data); -$connection->end($data = null); -$connection->close(); -// … +Under the hood, the `Connector` is implemented as a *higher-level facade* +for the lower-level connectors implemented in this package. This means it +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. + +In particular, the `Connector` class uses Google's public DNS server `8.8.8.8` +to resolve all hostnames into underlying IP addresses by default. +This implies that it also ignores your `hosts` file and `resolve.conf`, which +means you won't be able to connect to `localhost` and other non-public +hostnames by default. +If you want to use a custom DNS server (such as a local DNS relay), you can set +up the `Connector` like this: + +```php +$connector = new Connector($loop, array( + 'dns' => '127.0.1.1' +)); + +$connector->connect('localhost:80')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); ``` -For more details, see the -[`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface). +If you do not want to use a DNS resolver at all and want to connect to IP +addresses only, you can also set up your `Connector` like this: -#### getRemoteAddress() +```php +$connector = new Connector($loop, array( + 'dns' => false +)); -The `getRemoteAddress(): ?string` method returns the full remote address -(client IP and port) where this connection has been established from. +$connector->connect('127.0.0.1:80')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +Advanced: If you need a custom DNS `Resolver` instance, you can also set up +your `Connector` like this: ```php -$address = $connection->getRemoteAddress(); -echo 'Connection from ' . $address . PHP_EOL; +$dnsResolverFactory = new React\Dns\Resolver\Factory(); +$resolver = $dnsResolverFactory->createCached('127.0.1.1', $loop); + +$connector = new Connector($loop, array( + 'dns' => $resolver +)); + +$connector->connect('localhost:80')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); ``` -If the remote address can not be determined or is unknown at this time (such as -after the connection has been closed), it MAY return a `NULL` value instead. +By default, the `tcp://` and `tls://` URI schemes will use timeout value that +repects your `default_socket_timeout` ini setting (which defaults to 60s). +If you want a custom timeout value, you can simply pass this like this: -Otherwise, it will return the full remote address as a string value. -If this is a TCP/IP based connection and you only want the remote IP, you may -use something like this: +```php +$connector = new Connector($loop, array( + 'timeout' => 10.0 +)); +``` + +Similarly, if you do not want to apply a timeout at all and let the operating +system handle this, you can pass a boolean flag like this: ```php -$address = $connection->getRemoteAddress(); -$ip = trim(parse_url('tcp://' . $address, PHP_URL_HOST), '[]'); -echo 'Connection from ' . $ip . PHP_EOL; +$connector = new Connector($loop, array( + 'timeout' => false +)); ``` -#### getLocalAddress() +By default, the `Connector` supports the `tcp://`, `tls://` and `unix://` +URI schemes. If you want to explicitly prohibit any of these, you can simply +pass boolean flags like this: -The `getLocalAddress(): ?string` method returns the full local address -(client IP and port) where this connection has been established to. +```php +// only allow secure TLS connections +$connector = new Connector($loop, array( + 'tcp' => false, + 'tls' => true, + 'unix' => false, +)); + +$connector->connect('tls://google.com:443')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +The `tcp://` and `tls://` also accept additional context options passed to +the underlying connectors. +If you want to explicitly pass additional context options, you can simply +pass arrays of context options like this: ```php -$address = $connection->getLocalAddress(); -echo 'Connection to ' . $address . PHP_EOL; +// allow insecure TLS connections +$connector = new Connector($loop, array( + 'tcp' => array( + 'bindto' => '192.168.0.1:0' + ), + 'tls' => array( + 'verify_peer' => false, + 'verify_peer_name' => false + ), +)); + +$connector->connect('tls://localhost:443')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); ``` -If the local address can not be determined or is unknown at this time (such as -after the connection has been closed), it MAY return a `NULL` value instead. +> For more details about context options, please refer to the PHP documentation + about [socket context options](http://php.net/manual/en/context.socket.php) + and [SSL context options](http://php.net/manual/en/context.ssl.php). -Otherwise, it will return the full local address as a string value. +Advanced: By default, the `Connector` supports the `tcp://`, `tls://` and +`unix://` URI schemes. +For this, it sets up the required connector classes automatically. +If you want to explicitly pass custom connectors for any of these, you can simply +pass an instance implementing the `ConnectorInterface` like this: -This method complements the [`getRemoteAddress()`](#getremoteaddress) method, -so they should not be confused. -If your `Server` instance is listening on multiple interfaces (e.g. using -the address `0.0.0.0`), you can use this method to find out which interface -actually accepted this connection (such as a public or local interface). +```php +$dnsResolverFactory = new React\Dns\Resolver\Factory(); +$resolver = $dnsResolverFactory->createCached('127.0.1.1', $loop); +$tcp = new DnsConnector(new TcpConnector($loop), $resolver); + +$tls = new SecureConnector($tcp, $loop); + +$unix = new UnixConnector($loop); + +$connector = new Connector($loop, array( + 'tcp' => $tcp, + 'tls' => $tls, + 'unix' => $unix, + + 'dns' => false, + 'timeout' => false, +)); + +$connector->connect('google.com:80')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); +``` + +> Internally, the `tcp://` connector will always be wrapped by the DNS resolver, + unless you disable DNS like in the above example. In this case, the `tcp://` + connector receives the actual hostname instead of only the resolved IP address + and is thus responsible for performing the lookup. + Internally, the automatically created `tls://` connector will always wrap the + underlying `tcp://` connector for establishing the underlying plaintext + TCP/IP connection before enabling secure TLS mode. If you want to use a custom + underlying `tcp://` connector for secure TLS connections only, you may + explicitly pass a `tls://` connector like above instead. + Internally, the `tcp://` and `tls://` connectors will always be wrapped by + `TimeoutConnector`, unless you disable timeouts like in the above example. + +### Advanced client usage + +#### TcpConnector + +The `React\Socket\TcpConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and allows you to create plaintext +TCP/IP connections to any IP-port-combination: + +```php +$tcpConnector = new React\Socket\TcpConnector($loop); + +$tcpConnector->connect('127.0.0.1:80')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); + +$loop->run(); +``` + +See also the [first example](examples). + +Pending connection attempts can be cancelled by cancelling its pending promise like so: + +```php +$promise = $tcpConnector->connect('127.0.0.1:80'); + +$promise->cancel(); +``` + +Calling `cancel()` on a pending promise will close the underlying socket +resource, thus cancelling the pending TCP/IP connection, and reject the +resulting promise. + +You can optionally pass additional +[socket context options](http://php.net/manual/en/context.socket.php) +to the constructor like this: + +```php +$tcpConnector = new React\Socket\TcpConnector($loop, array( + 'bindto' => '192.168.0.1:0' +)); +``` + +Note that this class only allows you to connect to IP-port-combinations. +If the given URI is invalid, does not contain a valid IP address and port +or contains any other scheme, it will reject with an +`InvalidArgumentException`: + +If the given URI appears to be valid, but connecting to it fails (such as if +the remote host rejects the connection etc.), it will reject with a +`RuntimeException`. + +If you want to connect to hostname-port-combinations, see also the following chapter. + +> Advanced usage: Internally, the `TcpConnector` allocates an empty *context* +resource for each stream resource. +If the destination URI contains a `hostname` query parameter, its value will +be used to set up the TLS peer name. +This is used by the `SecureConnector` and `DnsConnector` to verify the peer +name and can also be used if you want a custom TLS peer name. + +#### DnsConnector + +The `DnsConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and allows you to create plaintext +TCP/IP connections to any hostname-port-combination. + +It does so by decorating a given `TcpConnector` instance so that it first +looks up the given domain name via DNS (if applicable) and then establishes the +underlying TCP/IP connection to the resolved target IP address. + +Make sure to set up your DNS resolver and underlying TCP connector like this: + +```php +$dnsResolverFactory = new React\Dns\Resolver\Factory(); +$dns = $dnsResolverFactory->createCached('8.8.8.8', $loop); + +$dnsConnector = new React\Socket\DnsConnector($tcpConnector, $dns); + +$dnsConnector->connect('www.google.com:80')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); +}); + +$loop->run(); +``` + +See also the [first example](examples). + +Pending connection attempts can be cancelled by cancelling its pending promise like so: + +```php +$promise = $dnsConnector->connect('www.google.com:80'); + +$promise->cancel(); +``` + +Calling `cancel()` on a pending promise will cancel the underlying DNS lookup +and/or the underlying TCP/IP connection and reject the resulting promise. + +> Advanced usage: Internally, the `DnsConnector` relies on a `Resolver` to +look up the IP address for the given hostname. +It will then replace the hostname in the destination URI with this IP and +append a `hostname` query parameter and pass this updated URI to the underlying +connector. +The underlying connector is thus responsible for creating a connection to the +target IP address, while this query parameter can be used to check the original +hostname and is used by the `TcpConnector` to set up the TLS peer name. +If a `hostname` is given explicitly, this query parameter will not be modified, +which can be useful if you want a custom TLS peer name. + +#### SecureConnector + +The `SecureConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and allows you to create secure +TLS (formerly known as SSL) connections to any hostname-port-combination. + +It does so by decorating a given `DnsConnector` instance so that it first +creates a plaintext TCP/IP connection and then enables TLS encryption on this +stream. + +```php +$secureConnector = new React\Socket\SecureConnector($dnsConnector, $loop); + +$secureConnector->connect('www.google.com:443')->then(function (ConnectionInterface $connection) { + $connection->write("GET / HTTP/1.0\r\nHost: www.google.com\r\n\r\n"); + ... +}); + +$loop->run(); +``` + +See also the [second example](examples). + +Pending connection attempts can be cancelled by cancelling its pending promise like so: + +```php +$promise = $secureConnector->connect('www.google.com:443'); + +$promise->cancel(); +``` + +Calling `cancel()` on a pending promise will cancel the underlying TCP/IP +connection and/or the SSL/TLS negonation and reject the resulting promise. + +You can optionally pass additional +[SSL context options](http://php.net/manual/en/context.ssl.php) +to the constructor like this: + +```php +$secureConnector = new React\Socket\SecureConnector($dnsConnector, $loop, array( + 'verify_peer' => false, + 'verify_peer_name' => false +)); +``` + +> Advanced usage: Internally, the `SecureConnector` relies on setting up the +required *context options* on the underlying stream resource. +It should therefor be used with a `TcpConnector` somewhere in the connector +stack so that it can allocate an empty *context* resource for each stream +resource and verify the peer name. +Failing to do so may result in a TLS peer name mismatch error or some hard to +trace race conditions, because all stream resources will use a single, shared +*default context* resource otherwise. + +#### TimeoutConnector + +The `TimeoutConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and allows you to add timeout +handling to any existing connector instance. + +It does so by decorating any given [`ConnectorInterface`](#connectorinterface) +instance and starting a timer that will automatically reject and abort any +underlying connection attempt if it takes too long. + +```php +$timeoutConnector = new React\Socket\TimeoutConnector($connector, 3.0, $loop); + +$timeoutConnector->connect('google.com:80')->then(function (ConnectionInterface $connection) { + // connection succeeded within 3.0 seconds +}); +``` + +See also any of the [examples](examples). + +Pending connection attempts can be cancelled by cancelling its pending promise like so: + +```php +$promise = $timeoutConnector->connect('google.com:80'); + +$promise->cancel(); +``` + +Calling `cancel()` on a pending promise will cancel the underlying connection +attempt, abort the timer and reject the resulting promise. + +#### UnixConnector + +The `UnixConnector` class implements the +[`ConnectorInterface`](#connectorinterface) and allows you to connect to +Unix domain socket (UDS) paths like this: + +```php +$connector = new React\Socket\UnixConnector($loop); + +$connector->connect('/tmp/demo.sock')->then(function (ConnectionInterface $connection) { + $connection->write("HELLO\n"); +}); + +$loop->run(); +``` + +Connecting to Unix domain sockets is an atomic operation, i.e. its promise will +settle (either resolve or reject) immediately. +As such, calling `cancel()` on the resulting promise has no effect. ## Install @@ -561,6 +1069,36 @@ $ composer require react/socket:^0.6 More details about version upgrades can be found in the [CHANGELOG](CHANGELOG.md). +This project supports running on legacy PHP 5.3 through current PHP 7+ and HHVM. +It's *highly recommended to use PHP 7+* for this project, partly due to its vast +performance improvements and partly because legacy PHP versions require several +workarounds as described below. + +Secure TLS connections received some major upgrades starting with PHP 5.6, with +the defaults now being more secure, while older versions required explicit +context options. +This library does not take responsibility over these context options, so it's +up to consumers of this library to take care of setting appropriate context +options as described above. + +All versions of PHP prior to 5.6.8 suffered from a buffering issue where reading +from a streaming TLS connection could be one `data` event behind. +This library implements a work-around to try to flush the complete incoming +data buffers on these versions, but we have seen reports of people saying this +could still affect some older versions (`5.5.23`, `5.6.7`, and `5.6.8`). +Note that this only affects *some* higher-level streaming protocols, such as +IRC over TLS, but should not affect HTTP over TLS (HTTPS). +Further investigation of this issue is needed. +For more insights, this issue is also covered by our test suite. + +This project also supports running on HHVM. +Note that really old HHVM < 3.8 does not support secure TLS connections, as it +lacks the required `stream_socket_enable_crypto()` function. +As such, trying to create a secure TLS connections on affected versions will +return a rejected promise instead. +This issue is also covered by our test suite, which will skip related tests +on affected versions. + ## Tests To run the test suite, you first need to clone this repo and then install all diff --git a/composer.json b/composer.json index 24ee2422..afdf3c51 100644 --- a/composer.json +++ b/composer.json @@ -1,19 +1,21 @@ { "name": "react/socket", - "description": "Async, streaming plaintext TCP/IP and secure TLS socket server for React PHP", - "keywords": ["socket"], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": ["async", "socket", "stream", "connection", "ReactPHP"], "license": "MIT", "require": { "php": ">=5.3.0", "evenement/evenement": "~2.0|~1.0", + "react/dns": "0.4.*|0.3.*", "react/event-loop": "0.4.*|0.3.*", "react/stream": "^0.6 || ^0.5 || ^0.4.5", - "react/promise": "^2.0 || ^1.1" + "react/promise": "^2.1 || ^1.2", + "react/promise-timer": "~1.0" }, "require-dev": { - "react/socket-client": "^0.6", "clue/block-react": "^1.1", - "phpunit/phpunit": "~4.8" + "phpunit/phpunit": "~4.8", + "react/stream": "^0.6" }, "autoload": { "psr-4": { diff --git a/examples/11-http.php b/examples/11-http.php new file mode 100644 index 00000000..72c585a0 --- /dev/null +++ b/examples/11-http.php @@ -0,0 +1,25 @@ +connect($target)->then(function (ConnectionInterface $connection) use ($target) { + $connection->on('data', function ($data) { + echo $data; + }); + $connection->on('close', function () { + echo '[CLOSED]' . PHP_EOL; + }); + + $connection->write("GET / HTTP/1.0\r\nHost: $target\r\n\r\n"); +}, 'printf'); + +$loop->run(); diff --git a/examples/12-https.php b/examples/12-https.php new file mode 100644 index 00000000..facc0472 --- /dev/null +++ b/examples/12-https.php @@ -0,0 +1,25 @@ +connect('tls://' . $target)->then(function (ConnectionInterface $connection) use ($target) { + $connection->on('data', function ($data) { + echo $data; + }); + $connection->on('close', function () { + echo '[CLOSED]' . PHP_EOL; + }); + + $connection->write("GET / HTTP/1.0\r\nHost: $target\r\n\r\n"); +}, 'printf'); + +$loop->run(); diff --git a/examples/13-netcat.php b/examples/13-netcat.php new file mode 100644 index 00000000..2873f3ba --- /dev/null +++ b/examples/13-netcat.php @@ -0,0 +1,50 @@ +' . PHP_EOL); + exit(1); +} + +$loop = Factory::create(); +$connector = new Connector($loop); + +$stdin = new ReadableResourceStream(STDIN, $loop); +$stdin->pause(); +$stdout = new WritableResourceStream(STDOUT, $loop); +$stderr = new WritableResourceStream(STDERR, $loop); + +$stderr->write('Connecting' . PHP_EOL); + +$connector->connect($argv[1])->then(function (ConnectionInterface $connection) use ($stdin, $stdout, $stderr) { + // pipe everything from STDIN into connection + $stdin->resume(); + $stdin->pipe($connection); + + // pipe everything from connection to STDOUT + $connection->pipe($stdout); + + // report errors to STDERR + $connection->on('error', function ($error) use ($stderr) { + $stderr->write('Stream ERROR: ' . $error . PHP_EOL); + }); + + // report closing and stop reading from input + $connection->on('close', function () use ($stderr, $stdin) { + $stderr->write('[CLOSED]' . PHP_EOL); + $stdin->close(); + }); + + $stderr->write('Connected' . PHP_EOL); +}, function ($error) use ($stderr) { + $stderr->write('Connection ERROR: ' . $error . PHP_EOL); +}); + +$loop->run(); diff --git a/examples/14-web.php b/examples/14-web.php new file mode 100644 index 00000000..58ee72d6 --- /dev/null +++ b/examples/14-web.php @@ -0,0 +1,47 @@ +' . PHP_EOL); + exit(1); +} + +$loop = Factory::create(); +$connector = new Connector($loop); + +if (!isset($parts['port'])) { + $parts['port'] = $parts['scheme'] === 'https' ? 443 : 80; +} + +$host = $parts['host']; +if (($parts['scheme'] === 'http' && $parts['port'] !== 80) || ($parts['scheme'] === 'https' && $parts['port'] !== 443)) { + $host .= ':' . $parts['port']; +} +$target = ($parts['scheme'] === 'https' ? 'tls' : 'tcp') . '://' . $parts['host'] . ':' . $parts['port']; +$resource = isset($parts['path']) ? $parts['path'] : '/'; +if (isset($parts['query'])) { + $resource .= '?' . $parts['query']; +} + +$stdout = new WritableResourceStream(STDOUT, $loop); + +$connector->connect($target)->then(function (ConnectionInterface $connection) use ($resource, $host, $stdout) { + $connection->pipe($stdout); + + $connection->write("GET $resource HTTP/1.0\r\nHost: $host\r\n\r\n"); +}, 'printf'); + +$loop->run(); diff --git a/examples/10-generate-self-signed.php b/examples/99-generate-self-signed.php similarity index 100% rename from examples/10-generate-self-signed.php rename to examples/99-generate-self-signed.php diff --git a/src/ConnectionInterface.php b/src/ConnectionInterface.php index af107530..5e2b7f60 100644 --- a/src/ConnectionInterface.php +++ b/src/ConnectionInterface.php @@ -5,22 +5,22 @@ use React\Stream\DuplexStreamInterface; /** - * Any incoming connection is represented by this interface. + * Any incoming and outgoing connection is represented by this interface, + * such as a normal TCP/IP connection. * - * An incoming connection is a duplex stream (both readable and writable) that - * implements React's DuplexStreamInterface. + * An incoming or outgoing connection is a duplex stream (both readable and + * writable) that implements React's + * [`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface). * It contains additional properties for the local and remote address (client IP) - * where this connection has been established from. + * where this connection has been established to/from. * - * Note that this interface is only to be used to represent the server-side end - * of an incoming connection. - * It MUST NOT be used to represent an outgoing connection in a client-side - * context. - * If you want to establish an outgoing connection, - * use React's SocketClient component instead. + * Most commonly, instances implementing this `ConnectionInterface` are emitted + * by all classes implementing the [`ServerInterface`](#serverinterface) and + * used by all classes implementing the [`ConnectorInterface`](#connectorinterface). * * Because the `ConnectionInterface` implements the underlying - * `DuplexStreamInterface` you can use any of its events and methods as usual: + * [`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface) + * you can use any of its events and methods as usual: * * ```php * $connection->on('data', function ($chunk) { @@ -49,15 +49,17 @@ * [`DuplexStreamInterface`](https://github.com/reactphp/stream#duplexstreaminterface). * * @see DuplexStreamInterface + * @see ServerInterface + * @see ConnectorInterface */ interface ConnectionInterface extends DuplexStreamInterface { /** - * Returns the remote address (client IP and port) where this connection has been established from + * Returns the remote address (client IP and port) where this connection has been established with * * ```php * $address = $connection->getRemoteAddress(); - * echo 'Connection from ' . $address . PHP_EOL; + * echo 'Connection with ' . $address . PHP_EOL; * ``` * * If the remote address can not be determined or is unknown at this time (such as @@ -70,7 +72,7 @@ interface ConnectionInterface extends DuplexStreamInterface * ```php * $address = $connection->getRemoteAddress(); * $ip = trim(parse_url('tcp://' . $address, PHP_URL_HOST), '[]'); - * echo 'Connection from ' . $ip . PHP_EOL; + * echo 'Connection with ' . $ip . PHP_EOL; * ``` * * @return ?string remote address (client IP and port) or null if unknown @@ -78,11 +80,11 @@ interface ConnectionInterface extends DuplexStreamInterface public function getRemoteAddress(); /** - * Returns the full local address (client IP and port) where this connection has been established to + * Returns the full local address (client IP and port) where this connection has been established with * * ```php * $address = $connection->getLocalAddress(); - * echo 'Connection to ' . $address . PHP_EOL; + * echo 'Connection with ' . $address . PHP_EOL; * ``` * * If the local address can not be determined or is unknown at this time (such as @@ -97,6 +99,10 @@ public function getRemoteAddress(); * the address `0.0.0.0`), you can use this method to find out which interface * actually accepted this connection (such as a public or local interface). * + * If your system has multiple interfaces (e.g. a WAN and a LAN interface), + * you can use this method to find out which interface was actually + * used for this connection. + * * @return ?string local address (client IP and port) or null if unknown * @see self::getRemoteAddress() */ diff --git a/src/Connector.php b/src/Connector.php new file mode 100644 index 00000000..ebae1e86 --- /dev/null +++ b/src/Connector.php @@ -0,0 +1,127 @@ + true, + 'tls' => true, + 'unix' => true, + + 'dns' => true, + 'timeout' => true, + ); + + if ($options['timeout'] === true) { + $options['timeout'] = (float)ini_get("default_socket_timeout"); + } + + if ($options['tcp'] instanceof ConnectorInterface) { + $tcp = $options['tcp']; + } else { + $tcp = new TcpConnector( + $loop, + is_array($options['tcp']) ? $options['tcp'] : array() + ); + } + + if ($options['dns'] !== false) { + if ($options['dns'] instanceof Resolver) { + $resolver = $options['dns']; + } else { + $factory = new Factory(); + $resolver = $factory->create( + $options['dns'] === true ? '8.8.8.8' : $options['dns'], + $loop + ); + } + + $tcp = new DnsConnector($tcp, $resolver); + } + + if ($options['tcp'] !== false) { + $options['tcp'] = $tcp; + + if ($options['timeout'] !== false) { + $options['tcp'] = new TimeoutConnector( + $options['tcp'], + $options['timeout'], + $loop + ); + } + + $this->connectors['tcp'] = $options['tcp']; + } + + if ($options['tls'] !== false) { + if (!$options['tls'] instanceof ConnectorInterface) { + $options['tls'] = new SecureConnector( + $tcp, + $loop, + is_array($options['tls']) ? $options['tls'] : array() + ); + } + + if ($options['timeout'] !== false) { + $options['tls'] = new TimeoutConnector( + $options['tls'], + $options['timeout'], + $loop + ); + } + + $this->connectors['tls'] = $options['tls']; + } + + if ($options['unix'] !== false) { + if (!$options['unix'] instanceof ConnectorInterface) { + $options['unix'] = new UnixConnector($loop); + } + $this->connectors['unix'] = $options['unix']; + } + } + + public function connect($uri) + { + $scheme = 'tcp'; + if (strpos($uri, '://') !== false) { + $scheme = (string)substr($uri, 0, strpos($uri, '://')); + } + + if (!isset($this->connectors[$scheme])) { + return Promise\reject(new RuntimeException( + 'No connector available for URI scheme "' . $scheme . '"' + )); + } + + return $this->connectors[$scheme]->connect($uri); + } +} + diff --git a/src/ConnectorInterface.php b/src/ConnectorInterface.php new file mode 100644 index 00000000..60be8ff8 --- /dev/null +++ b/src/ConnectorInterface.php @@ -0,0 +1,58 @@ +connect('google.com:443')->then( + * function (ConnectionInterface $connection) { + * // connection successfully established + * }, + * function (Exception $error) { + * // failed to connect due to $error + * } + * ); + * ``` + * + * The returned Promise MUST be implemented in such a way that it can be + * cancelled when it is still pending. Cancelling a pending promise MUST + * reject its value with an Exception. It SHOULD clean up any underlying + * resources and references as applicable. + * + * ```php + * $promise = $connector->connect($uri); + * + * $promise->cancel(); + * ``` + * + * @param string $uri + * @return React\Promise\PromiseInterface resolves with a stream implementing ConnectionInterface on success or rejects with an Exception on error + * @see ConnectionInterface + */ + public function connect($uri); +} diff --git a/src/DnsConnector.php b/src/DnsConnector.php new file mode 100644 index 00000000..15ceeec7 --- /dev/null +++ b/src/DnsConnector.php @@ -0,0 +1,109 @@ +connector = $connector; + $this->resolver = $resolver; + } + + public function connect($uri) + { + if (strpos($uri, '://') === false) { + $parts = parse_url('tcp://' . $uri); + unset($parts['scheme']); + } else { + $parts = parse_url($uri); + } + + if (!$parts || !isset($parts['host'])) { + return Promise\reject(new \InvalidArgumentException('Given URI "' . $uri . '" is invalid')); + } + + $host = trim($parts['host'], '[]'); + $connector = $this->connector; + + return $this + ->resolveHostname($host) + ->then(function ($ip) use ($connector, $host, $parts) { + $uri = ''; + + // prepend original scheme if known + if (isset($parts['scheme'])) { + $uri .= $parts['scheme'] . '://'; + } + + 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($parts['port'])) { + $uri .= ':' . $parts['port']; + } + + // append orignal path if known + if (isset($parts['path'])) { + $uri .= $parts['path']; + } + + // append original query if known + if (isset($parts['query'])) { + $uri .= '?' . $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($parts['query']) ? $parts['query'] : '', $args); + if ($host !== $ip && !isset($args['hostname'])) { + $uri .= (isset($parts['query']) ? '&' : '?') . 'hostname=' . rawurlencode($host); + } + + // append original fragment if known + if (isset($parts['fragment'])) { + $uri .= '#' . $parts['fragment']; + } + + return $connector->connect($uri); + }); + } + + private function resolveHostname($host) + { + if (false !== filter_var($host, FILTER_VALIDATE_IP)) { + return Promise\resolve($host); + } + + $promise = $this->resolver->resolve($host); + + return new Promise\Promise( + function ($resolve, $reject) use ($promise) { + // resolve/reject with result of DNS lookup + $promise->then($resolve, $reject); + }, + function ($_, $reject) use ($promise) { + // cancellation should reject connection attempt + $reject(new \RuntimeException('Connection attempt cancelled during DNS lookup')); + + // (try to) cancel pending DNS lookup + if ($promise instanceof CancellablePromiseInterface) { + $promise->cancel(); + } + } + ); + } +} diff --git a/src/SecureConnector.php b/src/SecureConnector.php new file mode 100644 index 00000000..e418864d --- /dev/null +++ b/src/SecureConnector.php @@ -0,0 +1,62 @@ +connector = $connector; + $this->streamEncryption = new StreamEncryption($loop, false); + $this->context = $context; + } + + public function connect($uri) + { + if (!function_exists('stream_socket_enable_crypto')) { + return Promise\reject(new \BadMethodCallException('Encryption not supported on your platform (HHVM < 3.8?)')); // @codeCoverageIgnore + } + + if (strpos($uri, '://') === false) { + $uri = 'tls://' . $uri; + } + + $parts = parse_url($uri); + if (!$parts || !isset($parts['scheme']) || $parts['scheme'] !== 'tls') { + return Promise\reject(new \InvalidArgumentException('Given URI "' . $uri . '" is invalid')); + } + + $uri = str_replace('tls://', '', $uri); + $context = $this->context; + + $encryption = $this->streamEncryption; + return $this->connector->connect($uri)->then(function (ConnectionInterface $connection) use ($context, $encryption) { + // (unencrypted) TCP/IP connection succeeded + + if (!$connection instanceof Stream) { + $connection->close(); + throw new \UnexpectedValueException('Connection MUST extend Stream in order to access underlying stream resource'); + } + + // set required SSL/TLS context options + foreach ($context as $name => $value) { + stream_context_set_option($connection->stream, 'ssl', $name, $value); + } + + // try to enable encryption + return $encryption->enable($connection)->then(null, function ($error) use ($connection) { + // establishing encryption failed => close invalid connection and return error + $connection->close(); + throw $error; + }); + }); + } +} diff --git a/src/SecureServer.php b/src/SecureServer.php index c117985c..2b7b0dc9 100644 --- a/src/SecureServer.php +++ b/src/SecureServer.php @@ -112,11 +112,16 @@ final class SecureServer extends EventEmitter implements ServerInterface * @param ServerInterface|Server $tcp * @param LoopInterface $loop * @param array $context + * @throws \BadMethodCallException for legacy HHVM < 3.8 due to lack of support * @see Server * @link http://php.net/manual/en/context.ssl.php for TLS context options */ public function __construct(ServerInterface $tcp, LoopInterface $loop, array $context) { + if (!function_exists('stream_socket_enable_crypto')) { + throw new \BadMethodCallException('Encryption not supported on your platform (HHVM < 3.8?)'); // @codeCoverageIgnore + } + // default to empty passphrase to surpress blocking passphrase prompt $context += array( 'passphrase' => '' diff --git a/src/StreamEncryption.php b/src/StreamEncryption.php index fd2fc0d3..ff804699 100644 --- a/src/StreamEncryption.php +++ b/src/StreamEncryption.php @@ -16,20 +16,18 @@ class StreamEncryption { private $loop; - private $method = STREAM_CRYPTO_METHOD_TLS_SERVER; + private $method; + private $server; private $errstr; private $errno; private $wrapSecure = false; - public function __construct(LoopInterface $loop) + public function __construct(LoopInterface $loop, $server = true) { - if (!function_exists('stream_socket_enable_crypto')) { - throw new \BadMethodCallException('Encryption not supported on your platform (HHVM < 3.8?)'); - } - $this->loop = $loop; + $this->server = $server; // See https://bugs.php.net/bug.php?id=65137 // https://bugs.php.net/bug.php?id=41631 @@ -40,14 +38,30 @@ public function __construct(LoopInterface $loop) $this->wrapSecure = true; } - if (defined('STREAM_CRYPTO_METHOD_TLSv1_0_SERVER')) { - $this->method |= STREAM_CRYPTO_METHOD_TLSv1_0_SERVER; - } - if (defined('STREAM_CRYPTO_METHOD_TLSv1_1_SERVER')) { - $this->method |= STREAM_CRYPTO_METHOD_TLSv1_1_SERVER; - } - if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_SERVER')) { - $this->method |= STREAM_CRYPTO_METHOD_TLSv1_2_SERVER; + if ($server) { + $this->method = STREAM_CRYPTO_METHOD_TLS_SERVER; + + if (defined('STREAM_CRYPTO_METHOD_TLSv1_0_SERVER')) { + $this->method |= STREAM_CRYPTO_METHOD_TLSv1_0_SERVER; + } + if (defined('STREAM_CRYPTO_METHOD_TLSv1_1_SERVER')) { + $this->method |= STREAM_CRYPTO_METHOD_TLSv1_1_SERVER; + } + if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_SERVER')) { + $this->method |= STREAM_CRYPTO_METHOD_TLSv1_2_SERVER; + } + } else { + $this->method = STREAM_CRYPTO_METHOD_TLS_CLIENT; + + if (defined('STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT')) { + $this->method |= STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT; + } + if (defined('STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT')) { + $this->method |= STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; + } + if (defined('STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT')) { + $this->method |= STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; + } } } @@ -68,7 +82,10 @@ public function toggle(Stream $stream, $toggle) // TODO: add write() event to make sure we're not sending any excessive data - $deferred = new Deferred(); + $deferred = new Deferred(function ($_, $reject) use ($toggle) { + // cancelling this leaves this stream in an inconsistent state… + $reject(new \RuntimeException('Cancelled toggling encryption ' . $toggle ? 'on' : 'off')); + }); // get actual stream socket from stream instance $socket = $stream->stream; @@ -80,9 +97,16 @@ public function toggle(Stream $stream, $toggle) $this->loop->addReadStream($socket, $toggleCrypto); + if (!$this->server) { + $toggleCrypto(); + } + $wrap = $this->wrapSecure && $toggle; + $loop = $this->loop; + + return $deferred->promise()->then(function () use ($stream, $socket, $wrap, $loop) { + $loop->removeReadStream($socket); - return $deferred->promise()->then(function () use ($stream, $wrap) { if ($wrap) { $stream->bufferSize = null; } @@ -90,7 +114,8 @@ public function toggle(Stream $stream, $toggle) $stream->resume(); return $stream; - }, function($error) use ($stream) { + }, function($error) use ($stream, $socket, $loop) { + $loop->removeReadStream($socket); $stream->resume(); throw $error; }); @@ -103,12 +128,8 @@ public function toggleCrypto($socket, Deferred $deferred, $toggle) restore_error_handler(); if (true === $result) { - $this->loop->removeStream($socket); - $deferred->resolve(); } else if (false === $result) { - $this->loop->removeStream($socket); - $deferred->reject(new UnexpectedValueException( sprintf("Unable to complete SSL/TLS handshake: %s", $this->errstr), $this->errno diff --git a/src/TcpConnector.php b/src/TcpConnector.php new file mode 100644 index 00000000..e248a45f --- /dev/null +++ b/src/TcpConnector.php @@ -0,0 +1,138 @@ +loop = $loop; + $this->context = $context; + } + + public function connect($uri) + { + if (strpos($uri, '://') === false) { + $uri = 'tcp://' . $uri; + } + + $parts = parse_url($uri); + if (!$parts || !isset($parts['scheme'], $parts['host'], $parts['port']) || $parts['scheme'] !== 'tcp') { + return Promise\reject(new \InvalidArgumentException('Given URI "' . $uri . '" is invalid')); + } + + $ip = trim($parts['host'], '[]'); + if (false === filter_var($ip, FILTER_VALIDATE_IP)) { + return Promise\reject(new \InvalidArgumentException('Given URI "' . $ip . '" does not contain a valid host IP')); + } + + // use context given in constructor + $context = array( + 'socket' => $this->context + ); + + // parse arguments from query component of URI + $args = array(); + if (isset($parts['query'])) { + parse_str($parts['query'], $args); + } + + // If an original hostname has been given, use this for TLS setup. + // This can happen due to layers of nested connectors, such as a + // DnsConnector reporting its original hostname. + // These context options are here in case TLS is enabled later on this stream. + // If TLS is not enabled later, this doesn't hurt either. + if (isset($args['hostname'])) { + $context['ssl'] = array( + 'SNI_enabled' => true, + 'peer_name' => $args['hostname'] + ); + + // Legacy PHP < 5.6 ignores peer_name and requires legacy context options instead. + // The SNI_server_name context option has to be set here during construction, + // as legacy PHP ignores any values set later. + if (PHP_VERSION_ID < 50600) { + $context['ssl'] += array( + 'SNI_server_name' => $args['hostname'], + 'CN_match' => $args['hostname'] + ); + } + } + + // HHVM fails to parse URIs with a query but no path, so let's add a dummy path + // See also https://3v4l.org/jEhLF + if (defined('HHVM_VERSION') && isset($parts['query']) && !isset($parts['path'])) { + $uri = str_replace('?', '/?', $uri); // @codeCoverageIgnore + } + + $socket = @stream_socket_client( + $uri, + $errno, + $errstr, + 0, + STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT, + stream_context_create($context) + ); + + if (false === $socket) { + return Promise\reject(new \RuntimeException( + sprintf("Connection to %s failed: %s", $uri, $errstr), + $errno + )); + } + + stream_set_blocking($socket, 0); + + // wait for connection + + return $this + ->waitForStreamOnce($socket) + ->then(array($this, 'checkConnectedSocket')) + ->then(array($this, 'handleConnectedSocket')); + } + + private function waitForStreamOnce($stream) + { + $loop = $this->loop; + + return new Promise\Promise(function ($resolve) use ($loop, $stream) { + $loop->addWriteStream($stream, function ($stream) use ($loop, $resolve) { + $loop->removeWriteStream($stream); + + $resolve($stream); + }); + }, function () use ($loop, $stream) { + $loop->removeWriteStream($stream); + fclose($stream); + + throw new \RuntimeException('Cancelled while waiting for TCP/IP connection to be established'); + }); + } + + /** @internal */ + public function checkConnectedSocket($socket) + { + // The following hack looks like the only way to + // detect connection refused errors with PHP's stream sockets. + if (false === stream_socket_get_name($socket, true)) { + fclose($socket); + + return Promise\reject(new \RuntimeException('Connection refused')); + } + + return Promise\resolve($socket); + } + + /** @internal */ + public function handleConnectedSocket($socket) + { + return new Connection($socket, $this->loop); + } +} diff --git a/src/TimeoutConnector.php b/src/TimeoutConnector.php new file mode 100644 index 00000000..4b04adee --- /dev/null +++ b/src/TimeoutConnector.php @@ -0,0 +1,26 @@ +connector = $connector; + $this->timeout = $timeout; + $this->loop = $loop; + } + + public function connect($uri) + { + return Timer\timeout($this->connector->connect($uri), $this->timeout, $this->loop); + } +} diff --git a/src/UnixConnector.php b/src/UnixConnector.php new file mode 100644 index 00000000..bb00f8dd --- /dev/null +++ b/src/UnixConnector.php @@ -0,0 +1,41 @@ +loop = $loop; + } + + public function connect($path) + { + if (strpos($path, '://') === false) { + $path = 'unix://' . $path; + } elseif (substr($path, 0, 7) !== 'unix://') { + return Promise\reject(new \InvalidArgumentException('Given URI "' . $path . '" is invalid')); + } + + $resource = @stream_socket_client($path, $errno, $errstr, 1.0); + + if (!$resource) { + return Promise\reject(new RuntimeException('Unable to connect to unix domain socket "' . $path . '": ' . $errstr, $errno)); + } + + return Promise\resolve(new Connection($resource, $this->loop)); + } +} diff --git a/tests/ConnectorTest.php b/tests/ConnectorTest.php new file mode 100644 index 00000000..c8eb19b7 --- /dev/null +++ b/tests/ConnectorTest.php @@ -0,0 +1,128 @@ +getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $promise = new Promise(function () { }); + $tcp = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $tcp->expects($this->once())->method('connect')->with('127.0.0.1:80')->willReturn($promise); + + $connector = new Connector($loop, array( + 'tcp' => $tcp + )); + + $connector->connect('127.0.0.1:80'); + } + + public function testConnectorPassedThroughHostnameIfDnsIsDisabled() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $promise = new Promise(function () { }); + $tcp = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $tcp->expects($this->once())->method('connect')->with('tcp://google.com:80')->willReturn($promise); + + $connector = new Connector($loop, array( + 'tcp' => $tcp, + 'dns' => false + )); + + $connector->connect('tcp://google.com:80'); + } + + public function testConnectorWithUnknownSchemeAlwaysFails() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connector = new Connector($loop); + + $promise = $connector->connect('unknown://google.com:80'); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testConnectorWithDisabledTcpDefaultSchemeAlwaysFails() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connector = new Connector($loop, array( + 'tcp' => false + )); + + $promise = $connector->connect('google.com:80'); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testConnectorWithDisabledTcpSchemeAlwaysFails() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connector = new Connector($loop, array( + 'tcp' => false + )); + + $promise = $connector->connect('tcp://google.com:80'); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testConnectorWithDisabledTlsSchemeAlwaysFails() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connector = new Connector($loop, array( + 'tls' => false + )); + + $promise = $connector->connect('tls://google.com:443'); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testConnectorWithDisabledUnixSchemeAlwaysFails() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $connector = new Connector($loop, array( + 'unix' => false + )); + + $promise = $connector->connect('unix://demo.sock'); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testConnectorUsesGivenResolverInstance() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $promise = new Promise(function () { }); + $resolver = $this->getMockBuilder('React\Dns\Resolver\Resolver')->disableOriginalConstructor()->getMock(); + $resolver->expects($this->once())->method('resolve')->with('google.com')->willReturn($promise); + + $connector = new Connector($loop, array( + 'dns' => $resolver + )); + + $connector->connect('google.com:80'); + } + + public function testConnectorUsesResolvedHostnameIfDnsIsUsed() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $promise = new Promise(function ($resolve) { $resolve('127.0.0.1'); }); + $resolver = $this->getMockBuilder('React\Dns\Resolver\Resolver')->disableOriginalConstructor()->getMock(); + $resolver->expects($this->once())->method('resolve')->with('google.com')->willReturn($promise); + + $promise = new Promise(function () { }); + $tcp = $this->getMockBuilder('React\Socket\ConnectorInterface')->getMock(); + $tcp->expects($this->once())->method('connect')->with('tcp://127.0.0.1:80?hostname=google.com')->willReturn($promise); + + $connector = new Connector($loop, array( + 'tcp' => $tcp, + 'dns' => $resolver + )); + + $connector->connect('tcp://google.com:80'); + } +} diff --git a/tests/DnsConnectorTest.php b/tests/DnsConnectorTest.php new file mode 100644 index 00000000..c33850b1 --- /dev/null +++ b/tests/DnsConnectorTest.php @@ -0,0 +1,111 @@ +tcp = $this->getMock('React\Socket\ConnectorInterface'); + $this->resolver = $this->getMockBuilder('React\Dns\Resolver\Resolver')->disableOriginalConstructor()->getMock(); + + $this->connector = new DnsConnector($this->tcp, $this->resolver); + } + + public function testPassByResolverIfGivenIp() + { + $this->resolver->expects($this->never())->method('resolve'); + $this->tcp->expects($this->once())->method('connect')->with($this->equalTo('127.0.0.1:80'))->will($this->returnValue(Promise\reject())); + + $this->connector->connect('127.0.0.1:80'); + } + + public function testPassThroughResolverIfGivenHost() + { + $this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('1.2.3.4'))); + $this->tcp->expects($this->once())->method('connect')->with($this->equalTo('1.2.3.4:80?hostname=google.com'))->will($this->returnValue(Promise\reject())); + + $this->connector->connect('google.com:80'); + } + + public function testPassThroughResolverIfGivenHostWhichResolvesToIpv6() + { + $this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('::1'))); + $this->tcp->expects($this->once())->method('connect')->with($this->equalTo('[::1]:80?hostname=google.com'))->will($this->returnValue(Promise\reject())); + + $this->connector->connect('google.com:80'); + } + + public function testPassByResolverIfGivenCompleteUri() + { + $this->resolver->expects($this->never())->method('resolve'); + $this->tcp->expects($this->once())->method('connect')->with($this->equalTo('scheme://127.0.0.1:80/path?query#fragment'))->will($this->returnValue(Promise\reject())); + + $this->connector->connect('scheme://127.0.0.1:80/path?query#fragment'); + } + + public function testPassThroughResolverIfGivenCompleteUri() + { + $this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('1.2.3.4'))); + $this->tcp->expects($this->once())->method('connect')->with($this->equalTo('scheme://1.2.3.4:80/path?query&hostname=google.com#fragment'))->will($this->returnValue(Promise\reject())); + + $this->connector->connect('scheme://google.com:80/path?query#fragment'); + } + + public function testPassThroughResolverIfGivenExplicitHost() + { + $this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('1.2.3.4'))); + $this->tcp->expects($this->once())->method('connect')->with($this->equalTo('scheme://1.2.3.4:80/?hostname=google.de'))->will($this->returnValue(Promise\reject())); + + $this->connector->connect('scheme://google.com:80/?hostname=google.de'); + } + + public function testRejectsImmediatelyIfUriIsInvalid() + { + $this->resolver->expects($this->never())->method('resolve'); + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('////'); + + $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); + } + + public function testSkipConnectionIfDnsFails() + { + $this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('example.invalid'))->will($this->returnValue(Promise\reject())); + $this->tcp->expects($this->never())->method('connect'); + + $this->connector->connect('example.invalid:80'); + } + + public function testCancelDuringDnsCancelsDnsAndDoesNotStartTcpConnection() + { + $pending = new Promise\Promise(function () { }, $this->expectCallableOnce()); + $this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('example.com'))->will($this->returnValue($pending)); + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('example.com:80'); + $promise->cancel(); + + $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); + } + + public function testCancelDuringTcpConnectionCancelsTcpConnection() + { + $pending = new Promise\Promise(function () { }, function () { throw new \Exception(); }); + $this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('example.com'))->will($this->returnValue(Promise\resolve('1.2.3.4'))); + $this->tcp->expects($this->once())->method('connect')->with($this->equalTo('1.2.3.4:80?hostname=example.com'))->will($this->returnValue($pending)); + + $promise = $this->connector->connect('example.com:80'); + $promise->cancel(); + + $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); + } +} diff --git a/tests/FunctionalSecureServerTest.php b/tests/FunctionalSecureServerTest.php index 9ffa24c1..53a6b7c1 100644 --- a/tests/FunctionalSecureServerTest.php +++ b/tests/FunctionalSecureServerTest.php @@ -7,8 +7,8 @@ use React\Socket\SecureServer; use React\Socket\ConnectionInterface; use React\Socket\Server; -use React\SocketClient\TcpConnector; -use React\SocketClient\SecureConnector; +use React\Socket\TcpConnector; +use React\Socket\SecureConnector; use Clue\React\Block; class FunctionalSecureServerTest extends TestCase diff --git a/tests/FunctionalServerTest.php b/tests/FunctionalServerTest.php index c6654523..b8a7093a 100644 --- a/tests/FunctionalServerTest.php +++ b/tests/FunctionalServerTest.php @@ -5,7 +5,7 @@ use React\EventLoop\Factory; use React\Socket\Server; use React\Socket\ConnectionInterface; -use React\SocketClient\TcpConnector; +use React\Socket\TcpConnector; use Clue\React\Block; class FunctionalServerTest extends TestCase diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php new file mode 100644 index 00000000..d5999693 --- /dev/null +++ b/tests/IntegrationTest.php @@ -0,0 +1,170 @@ +connect('google.com:80'), $loop); + + $this->assertContains(':80', $conn->getRemoteAddress()); + $this->assertNotEquals('google.com:80', $conn->getRemoteAddress()); + + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + $response = Block\await(BufferedSink::createPromise($conn), $loop, self::TIMEOUT); + + $this->assertRegExp('#^HTTP/1\.0#', $response); + } + + /** @test */ + public function gettingEncryptedStuffFromGoogleShouldWork() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = new StreamSelectLoop(); + $secureConnector = new Connector($loop); + + $conn = Block\await($secureConnector->connect('tls://google.com:443'), $loop); + + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + $response = Block\await(BufferedSink::createPromise($conn), $loop, self::TIMEOUT); + + $this->assertRegExp('#^HTTP/1\.0#', $response); + } + + /** @test */ + public function gettingEncryptedStuffFromGoogleShouldWorkIfHostIsResolvedFirst() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = new StreamSelectLoop(); + + $factory = new Factory(); + $dns = $factory->create('8.8.8.8', $loop); + + $connector = new DnsConnector( + new SecureConnector( + new TcpConnector($loop), + $loop + ), + $dns + ); + + $conn = Block\await($connector->connect('google.com:443'), $loop); + + $conn->write("GET / HTTP/1.0\r\n\r\n"); + + $response = Block\await(BufferedSink::createPromise($conn), $loop, self::TIMEOUT); + + $this->assertRegExp('#^HTTP/1\.0#', $response); + } + + /** @test */ + public function testConnectingFailsIfDnsUsesInvalidResolver() + { + $loop = new StreamSelectLoop(); + + $factory = new Factory(); + $dns = $factory->create('demo.invalid', $loop); + + $connector = new Connector($loop, array( + 'dns' => $dns + )); + + $this->setExpectedException('RuntimeException'); + Block\await($connector->connect('google.com:80'), $loop, self::TIMEOUT); + } + + /** @test */ + public function testConnectingFailsIfTimeoutIsTooSmall() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = new StreamSelectLoop(); + + $connector = new Connector($loop, array( + 'timeout' => 0.001 + )); + + $this->setExpectedException('RuntimeException'); + Block\await($connector->connect('google.com:80'), $loop, self::TIMEOUT); + } + + /** @test */ + public function testSelfSignedRejectsIfVerificationIsEnabled() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = new StreamSelectLoop(); + + $connector = new Connector($loop, array( + 'tls' => array( + 'verify_peer' => true + ) + )); + + $this->setExpectedException('RuntimeException'); + Block\await($connector->connect('tls://self-signed.badssl.com:443'), $loop, self::TIMEOUT); + } + + /** @test */ + public function testSelfSignedResolvesIfVerificationIsDisabled() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = new StreamSelectLoop(); + + $connector = new Connector($loop, array( + 'tls' => array( + 'verify_peer' => false + ) + )); + + $conn = Block\await($connector->connect('tls://self-signed.badssl.com:443'), $loop, self::TIMEOUT); + $conn->close(); + } + + public function testCancelPendingConnection() + { + $loop = new StreamSelectLoop(); + + $connector = new TcpConnector($loop); + $pending = $connector->connect('8.8.8.8:80'); + + $loop->addTimer(0.001, function () use ($pending) { + $pending->cancel(); + }); + + $pending->then($this->expectCallableNever(), $this->expectCallableOnce()); + + $loop->run(); + } +} diff --git a/tests/SecureConnectorTest.php b/tests/SecureConnectorTest.php new file mode 100644 index 00000000..e22c3c75 --- /dev/null +++ b/tests/SecureConnectorTest.php @@ -0,0 +1,74 @@ +markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $this->loop = $this->getMock('React\EventLoop\LoopInterface'); + $this->tcp = $this->getMock('React\Socket\ConnectorInterface'); + $this->connector = new SecureConnector($this->tcp, $this->loop); + } + + public function testConnectionWillWaitForTcpConnection() + { + $pending = new Promise\Promise(function () { }); + $this->tcp->expects($this->once())->method('connect')->with($this->equalTo('example.com:80'))->will($this->returnValue($pending)); + + $promise = $this->connector->connect('example.com:80'); + + $this->assertInstanceOf('React\Promise\PromiseInterface', $promise); + } + + public function testConnectionWithCompleteUriWillBePassedThroughExpectForScheme() + { + $pending = new Promise\Promise(function () { }); + $this->tcp->expects($this->once())->method('connect')->with($this->equalTo('example.com:80/path?query#fragment'))->will($this->returnValue($pending)); + + $this->connector->connect('tls://example.com:80/path?query#fragment'); + } + + public function testConnectionToInvalidSchemeWillReject() + { + $this->tcp->expects($this->never())->method('connect'); + + $promise = $this->connector->connect('tcp://example.com:80'); + + $promise->then(null, $this->expectCallableOnce()); + } + + public function testCancelDuringTcpConnectionCancelsTcpConnection() + { + $pending = new Promise\Promise(function () { }, function () { throw new \Exception(); }); + $this->tcp->expects($this->once())->method('connect')->with($this->equalTo('example.com:80'))->will($this->returnValue($pending)); + + $promise = $this->connector->connect('example.com:80'); + $promise->cancel(); + + $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); + } + + public function testConnectionWillBeClosedAndRejectedIfConnectioIsNoStream() + { + $connection = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + $connection->expects($this->once())->method('close'); + + $this->tcp->expects($this->once())->method('connect')->with($this->equalTo('example.com:80'))->willReturn(Promise\resolve($connection)); + + $promise = $this->connector->connect('example.com:80'); + + $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); + } +} diff --git a/tests/SecureIntegrationTest.php b/tests/SecureIntegrationTest.php new file mode 100644 index 00000000..ba290ca8 --- /dev/null +++ b/tests/SecureIntegrationTest.php @@ -0,0 +1,202 @@ +markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $this->loop = LoopFactory::create(); + $this->server = new Server(0, $this->loop); + $this->server = new SecureServer($this->server, $this->loop, array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + )); + $this->address = $this->server->getAddress(); + $this->connector = new SecureConnector(new TcpConnector($this->loop), $this->loop, array('verify_peer' => false)); + } + + public function tearDown() + { + if ($this->server !== null) { + $this->server->close(); + $this->server = null; + } + } + + public function testConnectToServer() + { + $client = Block\await($this->connector->connect($this->address), $this->loop, self::TIMEOUT); + /* @var $client Stream */ + + $client->close(); + } + + public function testConnectToServerEmitsConnection() + { + $promiseServer = $this->createPromiseForEvent($this->server, 'connection', $this->expectCallableOnce()); + + $promiseClient = $this->connector->connect($this->address); + + list($_, $client) = Block\awaitAll(array($promiseServer, $promiseClient), $this->loop, self::TIMEOUT); + /* @var $client Stream */ + + $client->close(); + } + + public function testSendSmallDataToServerReceivesOneChunk() + { + // server expects one connection which emits one data event + $received = new Deferred(); + $this->server->on('connection', function (Stream $peer) use ($received) { + $peer->on('data', function ($chunk) use ($received) { + $received->resolve($chunk); + }); + }); + + $client = Block\await($this->connector->connect($this->address), $this->loop, self::TIMEOUT); + /* @var $client Stream */ + + $client->write('hello'); + + // await server to report one "data" event + $data = Block\await($received->promise(), $this->loop, self::TIMEOUT); + + $client->close(); + + $this->assertEquals('hello', $data); + } + + public function testSendDataWithEndToServerReceivesAllData() + { + $disconnected = new Deferred(); + $this->server->on('connection', function (Stream $peer) use ($disconnected) { + $received = ''; + $peer->on('data', function ($chunk) use (&$received) { + $received .= $chunk; + }); + $peer->on('close', function () use (&$received, $disconnected) { + $disconnected->resolve($received); + }); + }); + + $client = Block\await($this->connector->connect($this->address), $this->loop, self::TIMEOUT); + /* @var $client Stream */ + + $data = str_repeat('a', 200000); + $client->end($data); + + // await server to report connection "close" event + $received = Block\await($disconnected->promise(), $this->loop, self::TIMEOUT); + + $this->assertEquals($data, $received); + } + + public function testSendDataWithoutEndingToServerReceivesAllData() + { + $received = ''; + $this->server->on('connection', function (Stream $peer) use (&$received) { + $peer->on('data', function ($chunk) use (&$received) { + $received .= $chunk; + }); + }); + + $client = Block\await($this->connector->connect($this->address), $this->loop, self::TIMEOUT); + /* @var $client Stream */ + + $data = str_repeat('d', 200000); + $client->write($data); + + // buffer incoming data for 0.1s (should be plenty of time) + Block\sleep(0.1, $this->loop); + + $client->close(); + + $this->assertEquals($data, $received); + } + + public function testConnectToServerWhichSendsSmallDataReceivesOneChunk() + { + $this->server->on('connection', function (Stream $peer) { + $peer->write('hello'); + }); + + $client = Block\await($this->connector->connect($this->address), $this->loop, self::TIMEOUT); + /* @var $client Stream */ + + // await client to report one "data" event + $receive = $this->createPromiseForEvent($client, 'data', $this->expectCallableOnceWith('hello')); + Block\await($receive, $this->loop, self::TIMEOUT); + + $client->close(); + } + + public function testConnectToServerWhichSendsDataWithEndReceivesAllData() + { + $data = str_repeat('b', 100000); + $this->server->on('connection', function (Stream $peer) use ($data) { + $peer->end($data); + }); + + $client = Block\await($this->connector->connect($this->address), $this->loop, self::TIMEOUT); + /* @var $client Stream */ + + // await data from client until it closes + $received = Block\await(BufferedSink::createPromise($client), $this->loop, self::TIMEOUT); + + $this->assertEquals($data, $received); + } + + public function testConnectToServerWhichSendsDataWithoutEndingReceivesAllData() + { + $data = str_repeat('c', 100000); + $this->server->on('connection', function (Stream $peer) use ($data) { + $peer->write($data); + }); + + $client = Block\await($this->connector->connect($this->address), $this->loop, self::TIMEOUT); + /* @var $client Stream */ + + // buffer incoming data for 0.1s (should be plenty of time) + $received = ''; + $client->on('data', function ($chunk) use (&$received) { + $received .= $chunk; + }); + Block\sleep(0.1, $this->loop); + + $client->close(); + + $this->assertEquals($data, $received); + } + + private function createPromiseForEvent(EventEmitterInterface $emitter, $event, $fn) + { + return new Promise(function ($resolve) use ($emitter, $event, $fn) { + $emitter->on($event, function () use ($resolve, $fn) { + $resolve(call_user_func_array($fn, func_get_args())); + }); + }); + } +} diff --git a/tests/TcpConnectorTest.php b/tests/TcpConnectorTest.php new file mode 100644 index 00000000..fef11151 --- /dev/null +++ b/tests/TcpConnectorTest.php @@ -0,0 +1,205 @@ +connect('127.0.0.1:9999') + ->then($this->expectCallableNever(), $this->expectCallableOnce()); + + $loop->run(); + } + + /** @test */ + public function connectionToTcpServerShouldSucceed() + { + $loop = new StreamSelectLoop(); + + $server = new Server(9999, $loop); + $server->on('connection', $this->expectCallableOnce()); + $server->on('connection', array($server, 'close')); + + $connector = new TcpConnector($loop); + + $connection = Block\await($connector->connect('127.0.0.1:9999'), $loop, self::TIMEOUT); + + $this->assertInstanceOf('React\Socket\ConnectionInterface', $connection); + + $connection->close(); + } + + /** @test */ + public function connectionToTcpServerShouldSucceedWithRemoteAdressSameAsTarget() + { + $loop = new StreamSelectLoop(); + + $server = new Server(9999, $loop); + $server->on('connection', array($server, 'close')); + + $connector = new TcpConnector($loop); + + $connection = Block\await($connector->connect('127.0.0.1:9999'), $loop, self::TIMEOUT); + /* @var $connection ConnectionInterface */ + + $this->assertEquals('127.0.0.1:9999', $connection->getRemoteAddress()); + + $connection->close(); + } + + /** @test */ + public function connectionToTcpServerShouldSucceedWithLocalAdressOnLocalhost() + { + $loop = new StreamSelectLoop(); + + $server = new Server(9999, $loop); + $server->on('connection', array($server, 'close')); + + $connector = new TcpConnector($loop); + + $connection = Block\await($connector->connect('127.0.0.1:9999'), $loop, self::TIMEOUT); + /* @var $connection ConnectionInterface */ + + $this->assertContains('127.0.0.1:', $connection->getLocalAddress()); + $this->assertNotEquals('127.0.0.1:9999', $connection->getLocalAddress()); + + $connection->close(); + } + + /** @test */ + public function connectionToTcpServerShouldSucceedWithNullAddressesAfterConnectionClosed() + { + $loop = new StreamSelectLoop(); + + $server = new Server(9999, $loop); + $server->on('connection', array($server, 'close')); + + $connector = new TcpConnector($loop); + + $connection = Block\await($connector->connect('127.0.0.1:9999'), $loop, self::TIMEOUT); + /* @var $connection ConnectionInterface */ + + $connection->close(); + + $this->assertNull($connection->getRemoteAddress()); + $this->assertNull($connection->getLocalAddress()); + } + + /** @test */ + public function connectionToEmptyIp6PortShouldFail() + { + $loop = new StreamSelectLoop(); + + $connector = new TcpConnector($loop); + $connector + ->connect('[::1]:9999') + ->then($this->expectCallableNever(), $this->expectCallableOnce()); + + $loop->run(); + } + + /** @test */ + public function connectionToIp6TcpServerShouldSucceed() + { + $loop = new StreamSelectLoop(); + + try { + $server = new Server('[::1]:9999', $loop); + } catch (\Exception $e) { + $this->markTestSkipped('Unable to start IPv6 server socket (IPv6 not supported on this system?)'); + } + + $server->on('connection', $this->expectCallableOnce()); + $server->on('connection', array($server, 'close')); + + $connector = new TcpConnector($loop); + + $connection = Block\await($connector->connect('[::1]:9999'), $loop, self::TIMEOUT); + /* @var $connection ConnectionInterface */ + + $this->assertEquals('[::1]:9999', $connection->getRemoteAddress()); + + $this->assertContains('[::1]:', $connection->getLocalAddress()); + $this->assertNotEquals('[::1]:9999', $connection->getLocalAddress()); + + $connection->close(); + } + + /** @test */ + public function connectionToHostnameShouldFailImmediately() + { + $loop = $this->getMock('React\EventLoop\LoopInterface'); + + $connector = new TcpConnector($loop); + $connector->connect('www.google.com:80')->then( + $this->expectCallableNever(), + $this->expectCallableOnce() + ); + } + + /** @test */ + public function connectionToInvalidPortShouldFailImmediately() + { + $loop = $this->getMock('React\EventLoop\LoopInterface'); + + $connector = new TcpConnector($loop); + $connector->connect('255.255.255.255:12345678')->then( + $this->expectCallableNever(), + $this->expectCallableOnce() + ); + } + + /** @test */ + public function connectionToInvalidSchemeShouldFailImmediately() + { + $loop = $this->getMock('React\EventLoop\LoopInterface'); + + $connector = new TcpConnector($loop); + $connector->connect('tls://google.com:443')->then( + $this->expectCallableNever(), + $this->expectCallableOnce() + ); + } + + /** @test */ + public function connectionWithInvalidContextShouldFailImmediately() + { + $this->markTestIncomplete(); + + $loop = $this->getMock('React\EventLoop\LoopInterface'); + + $connector = new TcpConnector($loop, array('bindto' => 'invalid.invalid:123456')); + $connector->connect('127.0.0.1:80')->then( + $this->expectCallableNever(), + $this->expectCallableOnce() + ); + } + + /** @test */ + public function cancellingConnectionShouldRejectPromise() + { + $loop = new StreamSelectLoop(); + $connector = new TcpConnector($loop); + + $server = new Server(0, $loop); + + $promise = $connector->connect($server->getAddress()); + $promise->cancel(); + + $this->setExpectedException('RuntimeException', 'Cancelled'); + Block\await($promise, $loop); + } +} diff --git a/tests/TimeoutConnectorTest.php b/tests/TimeoutConnectorTest.php new file mode 100644 index 00000000..333e8bfd --- /dev/null +++ b/tests/TimeoutConnectorTest.php @@ -0,0 +1,103 @@ +getMock('React\Socket\ConnectorInterface'); + $connector->expects($this->once())->method('connect')->with('google.com:80')->will($this->returnValue($promise)); + + $loop = Factory::create(); + + $timeout = new TimeoutConnector($connector, 0.01, $loop); + + $timeout->connect('google.com:80')->then( + $this->expectCallableNever(), + $this->expectCallableOnce() + ); + + $loop->run(); + } + + public function testRejectsWhenConnectorRejects() + { + $promise = Promise\reject(new \RuntimeException()); + + $connector = $this->getMock('React\Socket\ConnectorInterface'); + $connector->expects($this->once())->method('connect')->with('google.com:80')->will($this->returnValue($promise)); + + $loop = Factory::create(); + + $timeout = new TimeoutConnector($connector, 5.0, $loop); + + $timeout->connect('google.com:80')->then( + $this->expectCallableNever(), + $this->expectCallableOnce() + ); + + $loop->run(); + } + + public function testResolvesWhenConnectorResolves() + { + $promise = Promise\resolve(); + + $connector = $this->getMock('React\Socket\ConnectorInterface'); + $connector->expects($this->once())->method('connect')->with('google.com:80')->will($this->returnValue($promise)); + + $loop = Factory::create(); + + $timeout = new TimeoutConnector($connector, 5.0, $loop); + + $timeout->connect('google.com:80')->then( + $this->expectCallableOnce(), + $this->expectCallableNever() + ); + + $loop->run(); + } + + public function testRejectsAndCancelsPendingPromiseOnTimeout() + { + $promise = new Promise\Promise(function () { }, $this->expectCallableOnce()); + + $connector = $this->getMock('React\Socket\ConnectorInterface'); + $connector->expects($this->once())->method('connect')->with('google.com:80')->will($this->returnValue($promise)); + + $loop = Factory::create(); + + $timeout = new TimeoutConnector($connector, 0.01, $loop); + + $timeout->connect('google.com:80')->then( + $this->expectCallableNever(), + $this->expectCallableOnce() + ); + + $loop->run(); + } + + public function testCancelsPendingPromiseOnCancel() + { + $promise = new Promise\Promise(function () { }, function () { throw new \Exception(); }); + + $connector = $this->getMock('React\Socket\ConnectorInterface'); + $connector->expects($this->once())->method('connect')->with('google.com:80')->will($this->returnValue($promise)); + + $loop = Factory::create(); + + $timeout = new TimeoutConnector($connector, 0.01, $loop); + + $out = $timeout->connect('google.com:80'); + $out->cancel(); + + $out->then($this->expectCallableNever(), $this->expectCallableOnce()); + } +} diff --git a/tests/UnixConnectorTest.php b/tests/UnixConnectorTest.php new file mode 100644 index 00000000..deb826d7 --- /dev/null +++ b/tests/UnixConnectorTest.php @@ -0,0 +1,52 @@ +loop = $this->getMock('React\EventLoop\LoopInterface'); + $this->connector = new UnixConnector($this->loop); + } + + public function testInvalid() + { + $promise = $this->connector->connect('google.com:80'); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testInvalidScheme() + { + $promise = $this->connector->connect('tcp://google.com:80'); + $promise->then(null, $this->expectCallableOnce()); + } + + public function testValid() + { + // random unix domain socket path + $path = sys_get_temp_dir() . '/test' . uniqid() . '.sock'; + + // temporarily create unix domain socket server to connect to + $server = stream_socket_server('unix://' . $path, $errno, $errstr); + + // skip test if we can not create a test server (Windows etc.) + if (!$server) { + $this->markTestSkipped('Unable to create socket "' . $path . '": ' . $errstr . '(' . $errno .')'); + return; + } + + // tests succeeds if we get notified of successful connection + $promise = $this->connector->connect($path); + $promise->then($this->expectCallableOnce()); + + // clean up server + fclose($server); + unlink($path); + } +}