From 20849610ddf62fa860466790d79b6e84daa7bd27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 27 Apr 2017 13:49:51 +0200 Subject: [PATCH 1/4] New Server class acts as a facade for underlying TcpServer --- README.md | 100 +++++++++++++++++++++++++++++++++++++++++-- src/Server.php | 45 +++++++++++++++++++ src/TcpServer.php | 4 -- tests/ServerTest.php | 84 ++++++++++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 src/Server.php create mode 100644 tests/ServerTest.php diff --git a/README.md b/README.md index 68a1e1db..48991f8b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ handle multiple concurrent connections without blocking. * [pause()](#pause) * [resume()](#resume) * [close()](#close) + * [Server](#server) * [TcpServer](#tcpserver) * [SecureServer](#secureserver) * [LimitingServer](#limitingserver) @@ -319,6 +320,101 @@ $server->close(); Calling this method more than once on the same instance is a NO-OP. +### Server + +The `Server` class implements the [`ServerInterface`](#serverinterface) and +is responsible for accepting plaintext TCP/IP connections. +It acts as a facade for the underlying [`TcpServer`](#tcpserver) and follows +its exact semantics. + +```php +$server = new Server(8080, $loop); +``` + +As above, the `$uri` parameter can consist of only a port, in which case the +server will default to listening on the localhost address `127.0.0.1`, +which means it will not be reachable from outside of this system. + +In order to use a random port assignment, you can use the port `0`: + +```php +$server = new Server(0, $loop); +$address = $server->getAddress(); +``` + +In order to change the host the socket is listening on, you can provide an IP +address through the first parameter provided to the constructor, optionally +preceded by the `tcp://` scheme: + +```php +$server = new Server('192.168.0.1:8080', $loop); +``` + +If you want to listen on an IPv6 address, you MUST enclose the host in square +brackets: + +```php +$server = new Server('[::1]:8080', $loop); +``` + +If the given URI is invalid, does not contain a port, any other scheme or if it +contains a hostname, it will throw an `InvalidArgumentException`: + +```php +// throws InvalidArgumentException due to missing port +$server = new Server('127.0.0.1', $loop); +``` + +If the given URI appears to be valid, but listening on it fails (such as if port +is already in use or port below 1024 may require root access etc.), it will +throw a `RuntimeException`: + +```php +$first = new Server(8080, $loop); + +// throws RuntimeException because port is already in use +$second = new Server(8080, $loop); +``` + +> Note that these error conditions may vary depending on your system and/or +configuration. +See the exception message and code for more details about the actual error +condition. + +Optionally, you can specify [socket context options](http://php.net/manual/en/context.socket.php) +for the underlying stream socket resource like this: + +```php +$server = new Server('[::1]:8080', $loop, array( + 'backlog' => 200, + 'so_reuseport' => true, + 'ipv6_v6only' => true +)); +``` + +> Note that available [socket context options](http://php.net/manual/en/context.socket.php), +their defaults and effects of changing these may vary depending on your system +and/or PHP version. +Passing unknown context options has no effect. + +Whenever a client connects, it will emit a `connection` event with a connection +instance implementing [`ConnectionInterface`](#connectioninterface): + +```php +$server->on('connection', function (ConnectionInterface $connection) { + echo 'Plaintext connection from ' . $connection->getRemoteAddress() . PHP_EOL; + + $connection->write('hello there!' . PHP_EOL); + … +}); +``` + +See also the [`ServerInterface`](#serverinterface) for more details. + +> Note that the `Server` class is a concrete implementation for TCP/IP sockets. + If you want to typehint in your higher-level protocol implementation, you SHOULD + use the generic [`ServerInterface`](#serverinterface) instead. + ### TcpServer The `TcpServer` class implements the [`ServerInterface`](#serverinterface) and @@ -408,10 +504,6 @@ $server->on('connection', function (ConnectionInterface $connection) { See also the [`ServerInterface`](#serverinterface) for more details. -Note that the `TcpServer` class is a concrete implementation for TCP/IP sockets. -If you want to typehint in your higher-level protocol implementation, you SHOULD -use the generic [`ServerInterface`](#serverinterface) instead. - ### SecureServer The `SecureServer` class implements the [`ServerInterface`](#serverinterface) diff --git a/src/Server.php b/src/Server.php new file mode 100644 index 00000000..467179a6 --- /dev/null +++ b/src/Server.php @@ -0,0 +1,45 @@ +server = $server; + + $that = $this; + $server->on('connection', function (ConnectionInterface $conn) use ($that) { + $that->emit('connection', array($conn)); + }); + $server->on('error', function (\Exception $error) use ($that) { + $that->emit('error', array($error)); + }); + } + + public function getAddress() + { + return $this->server->getAddress(); + } + + public function pause() + { + $this->server->pause(); + } + + public function resume() + { + $this->server->resume(); + } + + public function close() + { + $this->server->close(); + } +} diff --git a/src/TcpServer.php b/src/TcpServer.php index 0923f649..576b450e 100644 --- a/src/TcpServer.php +++ b/src/TcpServer.php @@ -28,10 +28,6 @@ * * See also the `ServerInterface` for more details. * - * Note that the `TcpServer` class is a concrete implementation for TCP/IP sockets. - * If you want to typehint in your higher-level protocol implementation, you SHOULD - * use the generic `ServerInterface` instead. - * * @see ServerInterface * @see ConnectionInterface */ diff --git a/tests/ServerTest.php b/tests/ServerTest.php new file mode 100644 index 00000000..978e2ddc --- /dev/null +++ b/tests/ServerTest.php @@ -0,0 +1,84 @@ +getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $server = new Server('invalid URI', $loop); + } + + public function testEmitsConnectionForNewConnection() + { + $loop = Factory::create(); + + $server = new Server(0, $loop); + $server->on('connection', $this->expectCallableOnce()); + + $client = stream_socket_client($server->getAddress()); + + Block\sleep(0.1, $loop); + } + + public function testDoesNotEmitConnectionForNewConnectionToPausedServer() + { + $loop = Factory::create(); + + $server = new Server(0, $loop); + $server->pause(); + + + $client = stream_socket_client($server->getAddress()); + + Block\sleep(0.1, $loop); + } + + public function testDoesEmitConnectionForNewConnectionToResumedServer() + { + $loop = Factory::create(); + + $server = new Server(0, $loop); + $server->pause(); + $server->on('connection', $this->expectCallableOnce()); + + $client = stream_socket_client($server->getAddress()); + + Block\sleep(0.1, $loop); + + $server->resume(); + Block\sleep(0.1, $loop); + } + + public function testDoesNotAllowConnectionToClosedServer() + { + $loop = Factory::create(); + + $server = new Server(0, $loop); + $server->on('connection', $this->expectCallableNever()); + $address = $server->getAddress(); + $server->close(); + + $client = @stream_socket_client($address); + + Block\sleep(0.1, $loop); + + $this->assertFalse($client); + } +} From 13001a9cd7ad95442267db23a90687c97725b643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 27 Apr 2017 15:39:55 +0200 Subject: [PATCH 2/4] TCP socket context options should be nested in outer array --- README.md | 24 ++++++++++++++---------- src/Server.php | 12 +++++++++++- tests/ServerTest.php | 26 ++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 48991f8b..a8c10c10 100644 --- a/README.md +++ b/README.md @@ -377,25 +377,29 @@ $second = new Server(8080, $loop); ``` > Note that these error conditions may vary depending on your system and/or -configuration. -See the exception message and code for more details about the actual error -condition. + configuration. + See the exception message and code for more details about the actual error + condition. -Optionally, you can specify [socket context options](http://php.net/manual/en/context.socket.php) +Optionally, you can specify [TCP socket context options](http://php.net/manual/en/context.socket.php) for the underlying stream socket resource like this: ```php $server = new Server('[::1]:8080', $loop, array( - 'backlog' => 200, - 'so_reuseport' => true, - 'ipv6_v6only' => true + 'tcp' => array( + 'backlog' => 200, + 'so_reuseport' => true, + 'ipv6_v6only' => true + ) )); ``` > Note that available [socket context options](http://php.net/manual/en/context.socket.php), -their defaults and effects of changing these may vary depending on your system -and/or PHP version. -Passing unknown context options has no effect. + their defaults and effects of changing these may vary depending on your system + and/or PHP version. + Passing unknown context options has no effect. + For BC reasons, you can also pass the TCP socket context options as a simple + array without wrapping this in another array under the `tcp` key. Whenever a client connects, it will emit a `connection` event with a connection instance implementing [`ConnectionInterface`](#connectioninterface): diff --git a/src/Server.php b/src/Server.php index 467179a6..8ae92b16 100644 --- a/src/Server.php +++ b/src/Server.php @@ -11,7 +11,17 @@ final class Server extends EventEmitter implements ServerInterface public function __construct($uri, LoopInterface $loop, array $context = array()) { - $server = new TcpServer($uri, $loop, $context); + // sanitize TCP context options if not properly wrapped + if ($context && !isset($context['tcp'])) { + $context = array('tcp' => $context); + } + + // apply default options if not explicitly given + $context += array( + 'tcp' => array(), + ); + + $server = new TcpServer($uri, $loop, $context['tcp']); $this->server = $server; $that = $this; diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 978e2ddc..7b9577f2 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -5,6 +5,7 @@ use React\EventLoop\Factory; use React\Socket\Server; use Clue\React\Block; +use React\Socket\ConnectionInterface; class ServerTest extends TestCase { @@ -81,4 +82,29 @@ public function testDoesNotAllowConnectionToClosedServer() $this->assertFalse($client); } + + public function testEmitsConnectionWithInheritedContextOptions() + { + if (defined('HHVM_VERSION') && version_compare(HHVM_VERSION, '3.13', '<')) { + // https://3v4l.org/hB4Tc + $this->markTestSkipped('Not supported on legacy HHVM < 3.13'); + } + + $loop = Factory::create(); + + $server = new Server(0, $loop, array( + 'backlog' => 4 + )); + + $all = null; + $server->on('connection', function (ConnectionInterface $conn) use (&$all) { + $all = stream_context_get_options($conn->stream); + }); + + $client = stream_socket_client($server->getAddress()); + + Block\sleep(0.1, $loop); + + $this->assertEquals(array('socket' => array('backlog' => 4)), $all); + } } From 2a565374f071f556bb15fb908446a869455d532f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 28 Apr 2017 11:54:25 +0200 Subject: [PATCH 3/4] Support secure TLS connections via Server --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ src/Server.php | 16 ++++++++++++++-- tests/ServerTest.php | 20 ++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a8c10c10..b78b55db 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,48 @@ $server = new Server('[::1]:8080', $loop, array( For BC reasons, you can also pass the TCP socket context options as a simple array without wrapping this in another array under the `tcp` key. +You can start a secure TLS (formerly known as SSL) server by simply prepending +the `tls://` URI scheme. +Internally, it will wait for plaintext TCP/IP connections and then performs a +TLS handshake for each connection. +It thus requires valid [TLS context options](http://php.net/manual/en/context.ssl.php), +which in its most basic form may look something like this if you're using a +PEM encoded certificate file: + +```php +$server = new Server('tls://127.0.0.1:8080', $loop, array( + 'tls' => array( + 'local_cert' => 'server.pem' + ) +)); +``` + +> Note that the certificate file will not be loaded on instantiation but when an + incoming connection initializes its TLS context. + This implies that any invalid certificate file paths or contents will only cause + an `error` event at a later time. + +If your private key is encrypted with a passphrase, you have to specify it +like this: + +```php +$server = new Server('tls://127.0.0.1:8000', $loop, array( + 'tls' => array( + 'local_cert' => 'server.pem', + 'passphrase' => 'secret' + ) +)); +``` + +> Note that available [TLS context options](http://php.net/manual/en/context.ssl.php), + their defaults and effects of changing these may vary depending on your system + and/or PHP version. + The outer context array allows you to also use `tcp` (and possibly more) + context options at the same time. + Passing unknown context options has no effect. + If you do not use the `tls://` scheme, then passing `tls` context options + has no effect. + Whenever a client connects, it will emit a `connection` event with a connection instance implementing [`ConnectionInterface`](#connectioninterface): diff --git a/src/Server.php b/src/Server.php index 8ae92b16..86601f78 100644 --- a/src/Server.php +++ b/src/Server.php @@ -12,16 +12,28 @@ final class Server extends EventEmitter implements ServerInterface public function __construct($uri, LoopInterface $loop, array $context = array()) { // sanitize TCP context options if not properly wrapped - if ($context && !isset($context['tcp'])) { + if ($context && (!isset($context['tcp']) && !isset($context['tls']))) { $context = array('tcp' => $context); } // apply default options if not explicitly given $context += array( 'tcp' => array(), + 'tls' => array(), ); - $server = new TcpServer($uri, $loop, $context['tcp']); + $scheme = 'tcp'; + $pos = strpos($uri, '://'); + if ($pos !== false) { + $scheme = substr($uri, 0, $pos); + } + + $server = new TcpServer(str_replace('tls://', '', $uri), $loop, $context['tcp']); + + if ($scheme === 'tls') { + $server = new SecureServer($server, $loop, $context['tls']); + } + $this->server = $server; $that = $this; diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 7b9577f2..dfff30ef 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -107,4 +107,24 @@ public function testEmitsConnectionWithInheritedContextOptions() $this->assertEquals(array('socket' => array('backlog' => 4)), $all); } + + public function testDoesNotEmitSecureConnectionForNewPlainConnection() + { + if (!function_exists('stream_socket_enable_crypto')) { + $this->markTestSkipped('Not supported on your platform (outdated HHVM?)'); + } + + $loop = Factory::create(); + + $server = new Server('tls://127.0.0.1:0', $loop, array( + 'tls' => array( + 'local_cert' => __DIR__ . '/../examples/localhost.pem' + ) + )); + $server->on('connection', $this->expectCallableNever()); + + $client = stream_socket_client($server->getAddress()); + + Block\sleep(0.1, $loop); + } } From 1cb3bed80f488502fa0c6bbcdf08a4f01bafec58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 28 Apr 2017 16:09:03 +0200 Subject: [PATCH 4/4] Mark TcpServer and SecureServer as advanced usage --- README.md | 28 +++++++++++++++------------- examples/01-echo.php | 18 +++++++----------- examples/02-chat-server.php | 18 +++++++----------- examples/03-benchmark.php | 18 +++++++----------- 4 files changed, 36 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index b78b55db..f78683e2 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,11 @@ handle multiple concurrent connections without blocking. * [resume()](#resume) * [close()](#close) * [Server](#server) - * [TcpServer](#tcpserver) - * [SecureServer](#secureserver) - * [LimitingServer](#limitingserver) - * [getConnections()](#getconnections) + * [Advanced server usage](#advanced-server-usage) + * [TcpServer](#tcpserver) + * [SecureServer](#secureserver) + * [LimitingServer](#limitingserver) + * [getConnections()](#getconnections) * [Client usage](#client-usage) * [ConnectorInterface](#connectorinterface) * [connect()](#connect) @@ -55,8 +56,8 @@ Here is a server that closes the connection if you send it anything: ```php $loop = React\EventLoop\Factory::create(); +$socket = new React\Socket\Server('127.0.0.1:8080', $loop); -$socket = new React\Socket\TcpServer(8080, $loop); $socket->on('connection', function (ConnectionInterface $conn) { $conn->write("Hello " . $conn->getRemoteAddress() . "!\n"); $conn->write("Welcome to this amazing server!\n"); @@ -322,10 +323,9 @@ Calling this method more than once on the same instance is a NO-OP. ### Server -The `Server` class implements the [`ServerInterface`](#serverinterface) and -is responsible for accepting plaintext TCP/IP connections. -It acts as a facade for the underlying [`TcpServer`](#tcpserver) and follows -its exact semantics. +The `Server` class is the main class in this package that implements the +[`ServerInterface`](#serverinterface) and allows you to accept incoming +streaming connections, such as plaintext TCP/IP or secure TLS connection streams. ```php $server = new Server(8080, $loop); @@ -461,7 +461,9 @@ See also the [`ServerInterface`](#serverinterface) for more details. If you want to typehint in your higher-level protocol implementation, you SHOULD use the generic [`ServerInterface`](#serverinterface) instead. -### TcpServer +### Advanced server usage + +#### TcpServer The `TcpServer` class implements the [`ServerInterface`](#serverinterface) and is responsible for accepting plaintext TCP/IP connections. @@ -550,7 +552,7 @@ $server->on('connection', function (ConnectionInterface $connection) { See also the [`ServerInterface`](#serverinterface) for more details. -### SecureServer +#### SecureServer The `SecureServer` class implements the [`ServerInterface`](#serverinterface) and is responsible for providing a secure TLS (formerly known as SSL) server. @@ -630,7 +632,7 @@ If you use a custom `ServerInterface` and its `connection` event does not meet this requirement, the `SecureServer` will emit an `error` event and then close the underlying connection. -### LimitingServer +#### LimitingServer The `LimitingServer` decorator wraps a given `ServerInterface` and is responsible for limiting and keeping track of open connections to this server instance. @@ -697,7 +699,7 @@ $server->on('connection', function (ConnectionInterface $connection) { }); ``` -#### getConnections() +##### getConnections() The `getConnections(): ConnectionInterface[]` method can be used to return an array with all currently active connections. diff --git a/examples/01-echo.php b/examples/01-echo.php index f272ed07..96ec74d9 100644 --- a/examples/01-echo.php +++ b/examples/01-echo.php @@ -8,26 +8,22 @@ // // You can also run a secure TLS echo server like this: // -// $ php examples/01-echo.php 8000 examples/localhost.pem +// $ php examples/01-echo.php tls://127.0.0.1:8000 examples/localhost.pem // $ openssl s_client -connect localhost:8000 use React\EventLoop\Factory; -use React\Socket\TcpServer; +use React\Socket\Server; use React\Socket\ConnectionInterface; -use React\Socket\SecureServer; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); -$server = new TcpServer(isset($argv[1]) ? $argv[1] : 0, $loop); - -// secure TLS mode if certificate is given as second parameter -if (isset($argv[2])) { - $server = new SecureServer($server, $loop, array( - 'local_cert' => $argv[2] - )); -} +$server = new Server(isset($argv[1]) ? $argv[1] : 0, $loop, array( + 'tls' => array( + 'local_cert' => isset($argv[2]) ? $argv[2] : (__DIR__ . '/localhost.pem') + ) +)); $server->on('connection', function (ConnectionInterface $conn) { echo '[connected]' . PHP_EOL; diff --git a/examples/02-chat-server.php b/examples/02-chat-server.php index 7d2c17a8..a3bc59cf 100644 --- a/examples/02-chat-server.php +++ b/examples/02-chat-server.php @@ -8,27 +8,23 @@ // // You can also run a secure TLS chat server like this: // -// $ php examples/02-chat-server.php 8000 examples/localhost.pem +// $ php examples/02-chat-server.php tls://127.0.0.1:8000 examples/localhost.pem // $ openssl s_client -connect localhost:8000 use React\EventLoop\Factory; -use React\Socket\TcpServer; +use React\Socket\Server; use React\Socket\ConnectionInterface; -use React\Socket\SecureServer; use React\Socket\LimitingServer; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); -$server = new TcpServer(isset($argv[1]) ? $argv[1] : 0, $loop); - -// secure TLS mode if certificate is given as second parameter -if (isset($argv[2])) { - $server = new SecureServer($server, $loop, array( - 'local_cert' => $argv[2] - )); -} +$server = new Server(isset($argv[1]) ? $argv[1] : 0, $loop, array( + 'tls' => array( + 'local_cert' => isset($argv[2]) ? $argv[2] : (__DIR__ . '/localhost.pem') + ) +)); $server = new LimitingServer($server, null); diff --git a/examples/03-benchmark.php b/examples/03-benchmark.php index 87961fa2..8f71707a 100644 --- a/examples/03-benchmark.php +++ b/examples/03-benchmark.php @@ -11,28 +11,24 @@ // // You can also run a secure TLS benchmarking server like this: // -// $ php examples/03-benchmark.php 8000 examples/localhost.pem +// $ php examples/03-benchmark.php tls://127.0.0.1:8000 examples/localhost.pem // $ openssl s_client -connect localhost:8000 // $ echo hello world | openssl s_client -connect localhost:8000 // $ dd if=/dev/zero bs=1M count=1000 | openssl s_client -connect localhost:8000 use React\EventLoop\Factory; -use React\Socket\TcpServer; +use React\Socket\Server; use React\Socket\ConnectionInterface; -use React\Socket\SecureServer; require __DIR__ . '/../vendor/autoload.php'; $loop = Factory::create(); -$server = new TcpServer(isset($argv[1]) ? $argv[1] : 0, $loop); - -// secure TLS mode if certificate is given as second parameter -if (isset($argv[2])) { - $server = new SecureServer($server, $loop, array( - 'local_cert' => $argv[2] - )); -} +$server = new Server(isset($argv[1]) ? $argv[1] : 0, $loop, array( + 'tls' => array( + 'local_cert' => isset($argv[2]) ? $argv[2] : (__DIR__ . '/localhost.pem') + ) +)); $server->on('connection', function (ConnectionInterface $conn) use ($loop) { echo '[connected]' . PHP_EOL;