Browse Source

Implement TLS/SSL-encrypted connections.

This is handy for accessing remote Redis instances over a secure SSL connection
which is currently a popular option or even requirement with many cloud hosting
environments.

In order to configure the client to use an SSL-encrypted connection the scheme
in the connection parameters must be either "tsl" or "rediss" and a set of SSL
options (see http://php.net/manual/en/context.ssl.php) must be provided via the
"ssl" parameter as a named array.

The following example (which does not necessarily represent an example of good
practices!) illustrates how to set the "ssl" parameter using a named array and
the equivalent URI string:

  // Parameters as named array
  $parameters = [
    'scheme' => 'tls',
    'host'   => '127.0.0.1',
    'ssl'    => [
        'cafile'            => '/home/adaniele/redis.pem',
        'verify_peer_name'  => false,
    ],
  ];

  // Parameters as URI string
  $parameters = 'tls://127.0.0.1?ssl[cafile]=redis.pem&ssl[verify_peer_name]=1';

Support for SSL is currently limited to the Predis\Connection\StreamConnection
backend but we intend to investigate if it is possible to extend this feature
to Predis\Connection\PhpiredisStreamConnection in the future.

Be aware that using encrypted connections may lead to a performance degradation
especially in the connect() operation due to the overhead of the TLS handshake.
Unfortunately there is no real way to reuse SSL sessions from userland, aside
from enabling persistent connections, but this will work only on PHP >= 7.0.0
because previous versions of PHP do not provide enough info about a stream from
get_stream_meta_data().

NOTE: Redis does not have built-in support for SSL-encrypted connections, but if
you want to expose it to public networks you may want to rely on "stunnel".
Daniele Alessandri 9 years ago
parent
commit
ebb72377bb

+ 3 - 0
CHANGELOG.md

@@ -21,6 +21,9 @@ v1.1.0 (2015-xx-xx)
   The fallback to a default value is a responsibility of connection classes, but
   internally the default timeout for connect() operations is still 5 seconds.
 
+- Implemented support for SSL-encrypted connections, the connection parameters
+  must use either the `tls` or `rediss` scheme.
+
 
 v1.0.1 (2015-01-02)
 ================================================================================

+ 8 - 0
FAQ.md

@@ -18,6 +18,14 @@ least to some degree).
 Yes. Obviously persistent connections actually work only when using PHP configured as a persistent
 process reused by the web server (see [PHP-FPM](http://php-fpm.org)).
 
+### Does Predis support SSL-encrypted connections? ###
+
+Yes. Encrypted connections are mostly useful when connecting to Redis instances exposed by various
+cloud hosting providers without the need to configure an SSL proxy, but you should also take into
+account the general performances degradation especially during the connect() operation when the TLS
+handshake must be performed to secure the connection. Persistent SSL-encrypted connections may help
+in that respect, but they are supported only when running on PHP >= 7.0.0.
+
 ### Does Predis support transparent (de)serialization of values? ###
 
 No and it will not ever do that by default. The reason behind this decision is that serialization is

+ 22 - 4
README.md

@@ -32,8 +32,8 @@ More details about this project can be found on the [frequently asked questions]
 - Abstraction for Redis transactions (Redis >= 2.0) supporting CAS operations (Redis >= 2.2).
 - Abstraction for Lua scripting (Redis >= 2.6) with automatic switching between `EVALSHA` or `EVAL`.
 - Abstraction for `SCAN`, `SSCAN`, `ZSCAN` and `HSCAN` (Redis >= 2.8) based on PHP iterators.
-- Connections to Redis are established lazily by the client upon the first command.
-- Support for both TCP/IP and UNIX domain sockets and persistent connections.
+- Connections are established lazily by the client upon the first command and can be persisted.
+- Connections can be established via TCP/IP (optionally TLS/SSL-encrypted) or UNIX domain sockets.
 - Support for [Webdis](http://webd.is) (requires both `ext-curl` and `ext-phpiredis`).
 - Support for custom connection classes for providing different network or protocol backends.
 - Flexible system for defining custom commands and server profiles.
@@ -95,8 +95,26 @@ $client = new Predis\Client([
 $client = new Predis\Client('tcp://10.0.0.1:6379');
 ```
 
-Starting with Predis v1.0.2 the client also understands the `redis` scheme in URI strings as defined
-by the [provisional IANA registration](http://www.iana.org/assignments/uri-schemes/prov/redis).
+The client can leverage TLS/SSL encryption to connect to secured remote Redis instances without the
+the need to configure an SSL proxy like stunnel. This can be useful when connecting to nodes run by
+various cloud hosting providers. Encryption can be enabled with the use the `tls` scheme along with
+an array of suitable [options](http://php.net/manual/context.ssl.php) passed in the `ssl` parameter:
+
+```php
+// Named array of connection parameters:
+$client = new Predis\Client([
+  'scheme' => 'tls',
+  'ssl'    => ['cafile' => 'provate.pem', 'verify_peer' => true],
+]
+
+// Same set of parameters, but using an URI string:
+$client = new Predis\Client('tls://127.0.0.1?ssl[cafile]=private.pem&ssl[verify_peer]=1');
+```
+
+The connection schemes [`redis`](http://www.iana.org/assignments/uri-schemes/prov/redis) (alias of
+`tcp`) and [`rediss`](http://www.iana.org/assignments/uri-schemes/prov/rediss) (alias of `tls`) are
+also supported, with the difference that URI strings containing these schemes are parsed following
+the rules described on their respective IANA provisional registration documents.
 
 The actual list of supported connection parameters can vary depending on each connection backend so
 it is recommended to refer to their specific documentation or implementation for details.

+ 2 - 0
src/Connection/Factory.php

@@ -25,7 +25,9 @@ class Factory implements FactoryInterface
     protected $schemes = array(
         'tcp' => 'Predis\Connection\StreamConnection',
         'unix' => 'Predis\Connection\StreamConnection',
+        'tls' => 'Predis\Connection\StreamConnection',
         'redis' => 'Predis\Connection\StreamConnection',
+        'rediss' => 'Predis\Connection\StreamConnection',
         'http' => 'Predis\Connection\WebdisConnection',
     );
 

+ 2 - 12
src/Connection/PhpiredisStreamConnection.php

@@ -87,19 +87,9 @@ class PhpiredisStreamConnection extends StreamConnection
     /**
      * {@inheritdoc}
      */
-    protected function assertParameters(ParametersInterface $parameters)
+    protected function assertSslSupport(ParametersInterface $parameters)
     {
-        switch ($parameters->scheme) {
-            case 'tcp':
-            case 'redis':
-            case 'unix':
-                break;
-
-            default:
-                throw new \InvalidArgumentException("Invalid scheme: '$parameters->scheme'.");
-        }
-
-        return $parameters;
+        throw new \InvalidArgumentException('SSL encryption is not supported by this connection backend.');
     }
 
     /**

+ 66 - 1
src/Connection/StreamConnection.php

@@ -19,7 +19,7 @@ use Predis\Response\Status as StatusResponse;
  * Standard connection to Redis servers implemented on top of PHP's streams.
  * The connection parameters supported by this class are:.
  *
- *  - scheme: it can be either 'redis', 'tcp' or 'unix'.
+ *  - scheme: it can be either 'redis', 'tcp', 'rediss', 'tls' or 'unix'.
  *  - host: hostname or IP address of the server.
  *  - port: TCP port of the server.
  *  - path: path of a UNIX domain socket when scheme is 'unix'.
@@ -28,6 +28,7 @@ use Predis\Response\Status as StatusResponse;
  *  - async_connect: performs the connection asynchronously.
  *  - tcp_nodelay: enables or disables Nagle's algorithm for coalescing.
  *  - persistent: the connection is left intact after a GC collection.
+ *  - ssl: context options array (see http://php.net/manual/en/context.ssl.php)
  *
  * @author Daniele Alessandri <suppakilla@gmail.com>
  */
@@ -58,6 +59,11 @@ class StreamConnection extends AbstractConnection
             case 'unix':
                 break;
 
+            case 'tls':
+            case 'rediss':
+                $this->assertSslSupport($parameters);
+                break;
+
             default:
                 throw new \InvalidArgumentException("Invalid scheme: '$parameters->scheme'.");
         }
@@ -65,6 +71,23 @@ class StreamConnection extends AbstractConnection
         return $parameters;
     }
 
+    /**
+     * Checks needed conditions for SSL-encrypted connections.
+     *
+     * @param ParametersInterface $parameters Initialization parameters for the connection.
+     *
+     * @throws \InvalidArgumentException
+     */
+    protected function assertSslSupport(ParametersInterface $parameters)
+    {
+        if (
+            filter_var($parameters->persistent, FILTER_VALIDATE_BOOLEAN) &&
+            version_compare(PHP_VERSION, '7.0.0beta') < 0
+        ) {
+            throw new \InvalidArgumentException('Persistent SSL connections require PHP >= 7.0.0.');
+        }
+    }
+
     /**
      * {@inheritdoc}
      */
@@ -78,6 +101,10 @@ class StreamConnection extends AbstractConnection
             case 'unix':
                 return $this->unixStreamInitializer($this->parameters);
 
+            case 'tls':
+            case 'rediss':
+                return $this->tlsStreamInitializer($this->parameters);
+
             default:
                 throw new \InvalidArgumentException("Invalid scheme: '{$this->parameters->scheme}'.");
         }
@@ -179,6 +206,44 @@ class StreamConnection extends AbstractConnection
         return $resource;
     }
 
+    /**
+     * Initializes a SSL-encrypted TCP stream resource.
+     *
+     * @param ParametersInterface $parameters Initialization parameters for the connection.
+     *
+     * @return resource
+     */
+    protected function tlsStreamInitializer(ParametersInterface $parameters)
+    {
+        $resource = $this->tcpStreamInitializer($parameters);
+        $metadata = stream_get_meta_data($resource);
+
+        // Detect if crypto mode is already enabled for this stream (PHP >= 7.0.0).
+        if (isset($metadata['crypto'])) {
+            return $resource;
+        }
+
+        if (is_array($parameters->ssl)) {
+            $options = $parameters->ssl;
+        } else {
+            $options = array();
+        }
+
+        if (!isset($options['crypto_type'])) {
+            $options['crypto_type'] = STREAM_CRYPTO_METHOD_TLS_CLIENT;
+        }
+
+        if (!stream_context_set_option($resource, array('ssl' => $options))) {
+            $this->onConnectionError('Error while setting SSL context options');
+        }
+
+        if (!stream_socket_enable_crypto($resource, true, $options['crypto_type'])) {
+            $this->onConnectionError('Error while switching to encrypted communication');
+        }
+
+        return $resource;
+    }
+
     /**
      * {@inheritdoc}
      */

+ 20 - 0
tests/PHPUnit/PredisConnectionTestCase.php

@@ -48,6 +48,26 @@ abstract class PredisConnectionTestCase extends PredisTestCase
         $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $connection);
     }
 
+    /**
+     * @group disconnected
+     */
+    public function testSupportsSchemeTls()
+    {
+        $connection = $this->createConnectionWithParams(array('scheme' => 'tls'));
+
+        $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $connection);
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testSupportsSchemeRediss()
+    {
+        $connection = $this->createConnectionWithParams(array('scheme' => 'rediss'));
+
+        $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $connection);
+    }
+
     /**
      * @group disconnected
      */

+ 38 - 28
tests/Predis/Connection/FactoryTest.php

@@ -49,45 +49,55 @@ class FactoryTest extends PredisTestCase
     /**
      * @group disconnected
      */
-    public function testCreateConnection()
+    public function testCreateTcpConnection()
     {
         $factory = new Factory();
 
-        $tcp = new Parameters(array(
-            'scheme' => 'tcp',
-            'host' => 'locahost',
-        ));
+        $parameters = new Parameters(array('scheme' => 'tcp'));
+        $connection = $factory->create($parameters);
 
-        $connection = $factory->create($tcp);
-        $parameters = $connection->getParameters();
         $this->assertInstanceOf('Predis\Connection\StreamConnection', $connection);
-        $this->assertEquals($tcp->scheme, $parameters->scheme);
-        $this->assertEquals($tcp->host, $parameters->host);
-        $this->assertEquals($tcp->database, $parameters->database);
+        $this->assertSame($parameters, $connection->getParameters());
 
-        $tcp = new Parameters(array(
-            'scheme' => 'redis',
-            'host' => 'locahost',
-        ));
+        $parameters = new Parameters(array('scheme' => 'redis'));
+        $connection = $factory->create($parameters);
 
-        $connection = $factory->create($tcp);
-        $parameters = $connection->getParameters();
         $this->assertInstanceOf('Predis\Connection\StreamConnection', $connection);
-        $this->assertEquals($tcp->scheme, $parameters->scheme);
-        $this->assertEquals($tcp->host, $parameters->host);
-        $this->assertEquals($tcp->database, $parameters->database);
+        $this->assertSame($parameters, $connection->getParameters());
+    }
 
-        $unix = new Parameters(array(
-            'scheme' => 'unix',
-            'path' => '/tmp/redis.sock',
-        ));
+    /**
+     * @group disconnected
+     */
+    public function testCreateSslConnection()
+    {
+        $factory = new Factory();
+
+        $parameters = new Parameters(array('scheme' => 'tls'));
+        $connection = $factory->create($parameters);
+
+        $this->assertInstanceOf('Predis\Connection\StreamConnection', $connection);
+        $this->assertSame($parameters, $connection->getParameters());
+
+        $parameters = new Parameters(array('scheme' => 'rediss'));
+        $connection = $factory->create($parameters);
+
+        $this->assertInstanceOf('Predis\Connection\StreamConnection', $connection);
+        $this->assertSame($parameters, $connection->getParameters());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testCreateUnixConnection()
+    {
+        $factory = new Factory();
+
+        $parameters = new Parameters(array('scheme' => 'unix', 'path' => '/tmp/redis.sock'));
+        $connection = $factory->create($parameters);
 
-        $connection = $factory->create($unix);
-        $parameters = $connection->getParameters();
         $this->assertInstanceOf('Predis\Connection\StreamConnection', $connection);
-        $this->assertEquals($unix->scheme, $parameters->scheme);
-        $this->assertEquals($unix->path, $parameters->path);
-        $this->assertEquals($unix->database, $parameters->database);
+        $this->assertSame($parameters, $connection->getParameters());
     }
 
     /**

+ 24 - 0
tests/Predis/Connection/PhpiredisSocketConnectionTest.php

@@ -19,6 +19,30 @@ class PhpiredisSocketConnectionTest extends PredisConnectionTestCase
 {
     const CONNECTION_CLASS = 'Predis\Connection\PhpiredisSocketConnection';
 
+    /**
+     * @group disconnected
+     * @expectedException \InvalidArgumentException
+     * @expectedExceptionMessage Invalid scheme: 'tls'.
+     */
+    public function testSupportsSchemeTls()
+    {
+        $connection = $this->createConnectionWithParams(array('scheme' => 'tls'));
+
+        $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $connection);
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException \InvalidArgumentException
+     * @expectedExceptionMessage Invalid scheme: 'rediss'.
+     */
+    public function testSupportsSchemeRediss()
+    {
+        $connection = $this->createConnectionWithParams(array('scheme' => 'rediss'));
+
+        $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $connection);
+    }
+
     // ******************************************************************** //
     // ---- INTEGRATION TESTS --------------------------------------------- //
     // ******************************************************************** //

+ 24 - 0
tests/Predis/Connection/PhpiredisStreamConnectionTest.php

@@ -19,6 +19,30 @@ class PhpiredisStreamConnectionTest extends PredisConnectionTestCase
 {
     const CONNECTION_CLASS = 'Predis\Connection\PhpiredisStreamConnection';
 
+    /**
+     * @group disconnected
+     * @expectedException \InvalidArgumentException
+     * @expectedExceptionMessage SSL encryption is not supported by this connection backend.
+     */
+    public function testSupportsSchemeTls()
+    {
+        $connection = $this->createConnectionWithParams(array('scheme' => 'tls'));
+
+        $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $connection);
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException \InvalidArgumentException
+     * @expectedExceptionMessage SSL encryption is not supported by this connection backend.
+     */
+    public function testSupportsSchemeRediss()
+    {
+        $connection = $this->createConnectionWithParams(array('scheme' => 'rediss'));
+
+        $this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $connection);
+    }
+
     // ******************************************************************** //
     // ---- INTEGRATION TESTS --------------------------------------------- //
     // ******************************************************************** //