From 87c77d05944dd0b0aac111dee33aafde16fa4ff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 16 Feb 2017 08:24:53 +0100 Subject: [PATCH] Connections now resolve with a ConnectionInterface --- README.md | 124 +++++++++++++++++++++++++++++----- examples/01-http.php | 10 +-- examples/02-https.php | 10 +-- examples/03-netcat.php | 12 ++-- src/ConnectionInterface.php | 102 ++++++++++++++++++++++++++++ src/ConnectorInterface.php | 29 ++++++-- src/SecureConnector.php | 13 ++-- src/StreamConnection.php | 39 +++++++++++ src/TcpConnector.php | 2 +- tests/IntegrationTest.php | 3 + tests/SecureConnectorTest.php | 12 ++++ tests/TcpConnectorTest.php | 73 ++++++++++++++++++-- 12 files changed, 382 insertions(+), 47 deletions(-) create mode 100644 src/ConnectionInterface.php create mode 100644 src/StreamConnection.php diff --git a/README.md b/README.md index cfe35e7..2b1346a 100644 --- a/README.md +++ b/README.md @@ -50,15 +50,16 @@ The interface only offers a single method: #### connect() -The `connect(string $uri): PromiseInterface` method -can be used to establish a streaming connection. +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](https://github.com/reactphp/stream) or -rejects with an `Exception`: +fulfills with a stream implementing [`ConnectionInterface`](#connectioninterface) +on success or rejects with an `Exception` if the connection is not successful: ```php $connector->connect('google.com:443')->then( - function (Stream $stream) { + function (ConnectionInterface $connection) { // connection successfully established }, function (Exception $error) { @@ -67,6 +68,8 @@ $connector->connect('google.com:443')->then( ); ``` +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 @@ -78,6 +81,95 @@ $promise = $connector->connect($uri); $promise->cancel(); ``` +### ConnectionInterface + +The `ConnectionInterface` is used to represent any outgoing connection, +such as a normal TCP/IP connection. + +An 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 +where this connection has been established to. + +Most commonly, instances implementing this `ConnectionInterface` are returned +by all classes implementing the [`ConnectorInterface`](#connectorinterface). + +> Note that this interface is only to be used to represent the client-side end +of an outgoing connection. +It MUST NOT be used to represent an incoming connection in a server-side context. +If you want to accept incoming connections, +use the [`Socket`](https://github.com/reactphp/socket) component instead. + +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 $data; +}); + +$conenction->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 can be used to +return the remote address (IP and port) where this connection has been +established to. + +```php +$address = $connection->getRemoteAddress(); +echo 'Connected to ' . $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 'Connected to ' . $ip . PHP_EOL; +``` + +#### getLocalAddress() + +The `getLocalAddress(): ?string` method can be used to +return the full local address (IP and port) where this connection has been +established from. + +```php +$address = $connection->getLocalAddress(); +echo 'Connected via ' . $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 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. + ### Async TCP/IP connections The `React\SocketClient\TcpConnector` class implements the @@ -87,9 +179,9 @@ TCP/IP connections to any IP-port-combination: ```php $tcpConnector = new React\SocketClient\TcpConnector($loop); -$tcpConnector->connect('127.0.0.1:80')->then(function (React\Stream\Stream $stream) { - $stream->write('...'); - $stream->end(); +$tcpConnector->connect('127.0.0.1:80')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); }); $loop->run(); @@ -140,9 +232,9 @@ $dns = $dnsResolverFactory->connectCached('8.8.8.8', $loop); $dnsConnector = new React\SocketClient\DnsConnector($tcpConnector, $dns); -$dnsConnector->connect('www.google.com:80')->then(function (React\Stream\Stream $stream) { - $stream->write('...'); - $stream->end(); +$dnsConnector->connect('www.google.com:80')->then(function (ConnectionInterface $connection) { + $connection->write('...'); + $connection->end(); }); $loop->run(); @@ -184,8 +276,8 @@ stream. ```php $secureConnector = new React\SocketClient\SecureConnector($dnsConnector, $loop); -$secureConnector->connect('www.google.com:443')->then(function (React\Stream\Stream $stream) { - $stream->write("GET / HTTP/1.0\r\nHost: www.google.com\r\n\r\n"); +$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"); ... }); @@ -237,7 +329,7 @@ underlying connection attempt if it takes too long. ```php $timeoutConnector = new React\SocketClient\TimeoutConnector($connector, 3.0, $loop); -$timeoutConnector->connect('google.com:80')->then(function (React\Stream\Stream $stream) { +$timeoutConnector->connect('google.com:80')->then(function (ConnectionInterface $connection) { // connection succeeded within 3.0 seconds }); ``` @@ -264,8 +356,8 @@ Unix domain socket (UDS) paths like this: ```php $connector = new React\SocketClient\UnixConnector($loop); -$connector->connect('/tmp/demo.sock')->then(function (React\Stream\Stream $stream) { - $stream->write("HELLO\n"); +$connector->connect('/tmp/demo.sock')->then(function (ConnectionInterface $connection) { + $connection->write("HELLO\n"); }); $loop->run(); diff --git a/examples/01-http.php b/examples/01-http.php index 6a2f931..be7b1c0 100644 --- a/examples/01-http.php +++ b/examples/01-http.php @@ -3,8 +3,8 @@ use React\EventLoop\Factory; use React\SocketClient\TcpConnector; use React\SocketClient\DnsConnector; -use React\Stream\Stream; use React\SocketClient\TimeoutConnector; +use React\SocketClient\ConnectionInterface; require __DIR__ . '/../vendor/autoload.php'; @@ -19,15 +19,15 @@ // time out connection attempt in 3.0s $dns = new TimeoutConnector($dns, 3.0, $loop); -$dns->create('www.google.com', 80)->then(function (Stream $stream) { - $stream->on('data', function ($data) { +$dns->create('www.google.com', 80)->then(function (ConnectionInterface $connection) { + $connection->on('data', function ($data) { echo $data; }); - $stream->on('close', function () { + $connection->on('close', function () { echo '[CLOSED]' . PHP_EOL; }); - $stream->write("GET / HTTP/1.0\r\nHost: www.google.com\r\n\r\n"); + $connection->write("GET / HTTP/1.0\r\nHost: www.google.com\r\n\r\n"); }, 'printf'); $loop->run(); diff --git a/examples/02-https.php b/examples/02-https.php index c70ddcd..d18dce0 100644 --- a/examples/02-https.php +++ b/examples/02-https.php @@ -4,8 +4,8 @@ use React\SocketClient\TcpConnector; use React\SocketClient\DnsConnector; use React\SocketClient\SecureConnector; -use React\Stream\Stream; use React\SocketClient\TimeoutConnector; +use React\SocketClient\ConnectionInterface; require __DIR__ . '/../vendor/autoload.php'; @@ -21,15 +21,15 @@ // time out connection attempt in 3.0s $tls = new TimeoutConnector($tls, 3.0, $loop); -$tls->create('www.google.com', 443)->then(function (Stream $stream) { - $stream->on('data', function ($data) { +$tls->create('www.google.com', 443)->then(function (ConnectionInterface $connection) { + $connection->on('data', function ($data) { echo $data; }); - $stream->on('close', function () { + $connection->on('close', function () { echo '[CLOSED]' . PHP_EOL; }); - $stream->write("GET / HTTP/1.0\r\nHost: www.google.com\r\n\r\n"); + $connection->write("GET / HTTP/1.0\r\nHost: www.google.com\r\n\r\n"); }, 'printf'); $loop->run(); diff --git a/examples/03-netcat.php b/examples/03-netcat.php index 8ef34ad..5ede41a 100644 --- a/examples/03-netcat.php +++ b/examples/03-netcat.php @@ -3,8 +3,8 @@ use React\EventLoop\Factory; use React\SocketClient\TcpConnector; use React\SocketClient\DnsConnector; -use React\Stream\Stream; use React\SocketClient\TimeoutConnector; +use React\SocketClient\ConnectionInterface; require __DIR__ . '/../vendor/autoload.php'; @@ -33,21 +33,21 @@ $stderr->write('Connecting' . PHP_EOL); -$dns->create($argv[1], $argv[2])->then(function (Stream $stream) use ($stdin, $stdout, $stderr) { +$dns->create($argv[1], $argv[2])->then(function (ConnectionInterface $connection) use ($stdin, $stdout, $stderr) { // pipe everything from STDIN into connection $stdin->resume(); - $stdin->pipe($stream); + $stdin->pipe($connection); // pipe everything from connection to STDOUT - $stream->pipe($stdout); + $connection->pipe($stdout); // report errors to STDERR - $stream->on('error', function ($error) use ($stderr) { + $connection->on('error', function ($error) use ($stderr) { $stderr->write('Stream ERROR: ' . $error . PHP_EOL); }); // report closing and stop reading from input - $stream->on('close', function () use ($stderr, $stdin) { + $connection->on('close', function () use ($stderr, $stdin) { $stderr->write('[CLOSED]' . PHP_EOL); $stdin->close(); }); diff --git a/src/ConnectionInterface.php b/src/ConnectionInterface.php new file mode 100644 index 0000000..0687e75 --- /dev/null +++ b/src/ConnectionInterface.php @@ -0,0 +1,102 @@ + Note that this interface is only to be used to represent the client-side end + * of an outgoing connection. + * It MUST NOT be used to represent an incoming connection in a server-side context. + * If you want to accept incoming connections, + * use the [`Socket`](https://github.com/reactphp/socket) component instead. + * + * 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 $data; + * }); + * + * $conenction->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). + * + * @see DuplexStreamInterface + * @see ConnectorInterface + */ +interface ConnectionInterface extends DuplexStreamInterface +{ + /** + * Returns the remote address (IP and port) where this connection has been established to + * + * ```php + * $address = $connection->getRemoteAddress(); + * echo 'Connected to ' . $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 'Connected to ' . $ip . PHP_EOL; + * ``` + * + * @return ?string remote address (IP and port) or null if unknown + */ + public function getRemoteAddress(); + + /** + * Returns the full local address (IP and port) where this connection has been established from + * + * ```php + * $address = $connection->getLocalAddress(); + * echo 'Connected via ' . $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 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 (IP and port) or null if unknown + * @see self::getRemoteAddress() + */ + public function getLocalAddress(); +} diff --git a/src/ConnectorInterface.php b/src/ConnectorInterface.php index 46b0182..5700297 100644 --- a/src/ConnectorInterface.php +++ b/src/ConnectorInterface.php @@ -16,22 +16,43 @@ * swap this implementation against any other implementation of this interface. * * The interface only offers a single `connect()` method. + * + * @see ConnectionInterface */ interface ConnectorInterface { /** - * Creates a Promise which resolves with a stream once the connection to the given remote address succeeds + * Creates a streaming connection to the given remote address + * + * If returns a Promise which either fulfills with a stream implementing + * `ConnectionInterface` on success or rejects with an `Exception` if the + * connection is not successful. * - * The Promise resolves with a `React\Stream\Stream` instance on success or - * rejects with an `Exception` if the connection is not successful. + * ```php + * $connector->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 on success or rejects with an Exception on error + * @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/SecureConnector.php b/src/SecureConnector.php index 09882e8..1704674 100644 --- a/src/SecureConnector.php +++ b/src/SecureConnector.php @@ -52,18 +52,23 @@ public function connect($uri) } $encryption = $this->streamEncryption; - return $this->connector->connect($uri)->then(function (Stream $stream) use ($context, $encryption) { + 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($stream->stream, 'ssl', $name, $value); + stream_context_set_option($connection->stream, 'ssl', $name, $value); } // try to enable encryption - return $encryption->enable($stream)->then(null, function ($error) use ($stream) { + return $encryption->enable($connection)->then(null, function ($error) use ($connection) { // establishing encryption failed => close invalid connection and return error - $stream->close(); + $connection->close(); throw $error; }); }); diff --git a/src/StreamConnection.php b/src/StreamConnection.php new file mode 100644 index 0000000..4d883da --- /dev/null +++ b/src/StreamConnection.php @@ -0,0 +1,39 @@ +sanitizeAddress(@stream_socket_get_name($this->stream, true)); + } + + public function getLocalAddress() + { + return $this->sanitizeAddress(@stream_socket_get_name($this->stream, false)); + } + + private function sanitizeAddress($address) + { + if ($address === false) { + return null; + } + + // check if this is an IPv6 address which includes multiple colons but no square brackets + $pos = strrpos($address, ':'); + if ($pos !== false && strpos($address, ':') < $pos && substr($address, 0, 1) !== '[') { + $port = substr($address, $pos + 1); + $address = '[' . substr($address, 0, $pos) . ']:' . $port; + } + + return $address; + } +} diff --git a/src/TcpConnector.php b/src/TcpConnector.php index d13f02e..8e4890d 100644 --- a/src/TcpConnector.php +++ b/src/TcpConnector.php @@ -96,6 +96,6 @@ public function checkConnectedSocket($socket) /** @internal */ public function handleConnectedSocket($socket) { - return new Stream($socket, $this->loop); + return new StreamConnection($socket, $this->loop); } } diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index 4c64973..70951c8 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -26,6 +26,9 @@ public function gettingStuffFromGoogleShouldWork() $conn = Block\await($connector->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); diff --git a/tests/SecureConnectorTest.php b/tests/SecureConnectorTest.php index b05af08..ad7de59 100644 --- a/tests/SecureConnectorTest.php +++ b/tests/SecureConnectorTest.php @@ -59,4 +59,16 @@ public function testCancelDuringTcpConnectionCancelsTcpConnection() $promise->then($this->expectCallableNever(), $this->expectCallableOnce()); } + + public function testConnectionWillBeClosedAndRejectedIfConnectioIsNoStream() + { + $connection = $this->getMockBuilder('React\SocketClient\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/TcpConnectorTest.php b/tests/TcpConnectorTest.php index 75bef72..5e48feb 100644 --- a/tests/TcpConnectorTest.php +++ b/tests/TcpConnectorTest.php @@ -5,6 +5,7 @@ use React\EventLoop\StreamSelectLoop; use React\Socket\Server; use React\SocketClient\TcpConnector; +use React\SocketClient\ConnectionInterface; use Clue\React\Block; class TcpConnectorTest extends TestCase @@ -34,11 +35,67 @@ public function connectionToTcpServerShouldSucceed() $connector = new TcpConnector($loop); - $stream = Block\await($connector->connect('127.0.0.1:9999'), $loop, self::TIMEOUT); + $connection = Block\await($connector->connect('127.0.0.1:9999'), $loop, self::TIMEOUT); - $this->assertInstanceOf('React\Stream\Stream', $stream); + $this->assertInstanceOf('React\SocketClient\ConnectionInterface', $connection); - $stream->close(); + $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 */ @@ -70,11 +127,15 @@ public function connectionToIp6TcpServerShouldSucceed() $connector = new TcpConnector($loop); - $stream = Block\await($connector->connect('[::1]:9999'), $loop, self::TIMEOUT); + $connection = Block\await($connector->connect('[::1]:9999'), $loop, self::TIMEOUT); + /* @var $connection ConnectionInterface */ + + $this->assertEquals('[::1]:9999', $connection->getRemoteAddress()); - $this->assertInstanceOf('React\Stream\Stream', $stream); + $this->assertContains('[::1]:', $connection->getLocalAddress()); + $this->assertNotEquals('[::1]:9999', $connection->getLocalAddress()); - $stream->close(); + $connection->close(); } /** @test */