diff --git a/cache/stores/redis/addinstanceform.php b/cache/stores/redis/addinstanceform.php index 1a3faa16c37..cdd9ed653a3 100644 --- a/cache/stores/redis/addinstanceform.php +++ b/cache/stores/redis/addinstanceform.php @@ -44,6 +44,14 @@ class cachestore_redis_addinstance_form extends cachestore_addinstance_form { $form->addHelpButton('server', 'server', 'cachestore_redis'); $form->addRule('server', get_string('required'), 'required'); + $form->addElement('advcheckbox', 'encryption', get_string('encrypt_connection', 'cachestore_redis')); + $form->setType('encryption', PARAM_BOOL); + $form->addHelpButton('encryption', 'encrypt_connection', 'cachestore_redis'); + + $form->addElement('text', 'cafile', get_string('ca_file', 'cachestore_redis')); + $form->setType('cafile', PARAM_TEXT); + $form->addHelpButton('cafile', 'ca_file', 'cachestore_redis'); + $form->addElement('passwordunmask', 'password', get_string('password', 'cachestore_redis')); $form->setType('password', PARAM_RAW); $form->addHelpButton('password', 'password', 'cachestore_redis'); diff --git a/cache/stores/redis/lang/en/cachestore_redis.php b/cache/stores/redis/lang/en/cachestore_redis.php index f323d312a5f..7b6cddb30c9 100644 --- a/cache/stores/redis/lang/en/cachestore_redis.php +++ b/cache/stores/redis/lang/en/cachestore_redis.php @@ -27,6 +27,10 @@ defined('MOODLE_INTERNAL') || die(); $string['compressor_none'] = 'No compression.'; $string['compressor_php_gzip'] = 'Use gzip compression.'; $string['compressor_php_zstd'] = 'Use Zstandard compression.'; +$string['encrypt_connection'] = 'Use TLS encryption.'; +$string['encrypt_connection_help'] = 'Use TLS to connect to Redis. Do not use \'tls://\' in the hostname for Redis, use this option instead.'; +$string['ca_file'] = 'CA file path'; +$string['ca_file_help'] = 'Location of Certificate Authority file on local filesystem'; $string['pluginname'] = 'Redis'; $string['prefix'] = 'Key prefix'; $string['prefix_help'] = 'This prefix is used for all key names on the Redis server. diff --git a/cache/stores/redis/lib.php b/cache/stores/redis/lib.php index c28120020a0..3c72c7d7e0e 100644 --- a/cache/stores/redis/lib.php +++ b/cache/stores/redis/lib.php @@ -189,42 +189,60 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_ if (array_key_exists('compressor', $configuration)) { $this->compressor = (int)$configuration['compressor']; } - $password = !empty($configuration['password']) ? $configuration['password'] : ''; - $prefix = !empty($configuration['prefix']) ? $configuration['prefix'] : ''; if (array_key_exists('lockwait', $configuration)) { $this->lockwait = (int)$configuration['lockwait']; } if (array_key_exists('locktimeout', $configuration)) { $this->locktimeout = (int)$configuration['locktimeout']; } - $this->redis = $this->new_redis($configuration['server'], $prefix, $password); + $this->redis = $this->new_redis($configuration); } /** * Create a new Redis instance and * connect to the server. * - * @param string $server The server connection string - * @param string $prefix The key prefix - * @param string $password The server connection password + * @param array $configuration The server configuration * @return Redis */ - protected function new_redis($server, $prefix = '', $password = '') { + protected function new_redis(array $configuration): \Redis { + global $CFG; + $redis = new Redis(); - // Check for Unix socket. + + $server = $configuration['server']; + $encrypt = (bool) ($configuration['encryption'] ?? false); + $password = !empty($configuration['password']) ? $configuration['password'] : ''; + $prefix = !empty($configuration['prefix']) ? $configuration['prefix'] : ''; + // Check if it isn't a Unix socket to set default port. + $port = null; + $opts = []; if ($server[0] === '/') { $port = 0; } else { $port = 6379; // No Unix socket so set default port. if (strpos($server, ':')) { // Check for custom port. - $serverconf = explode(':', $server); - $server = $serverconf[0]; - $port = $serverconf[1]; + list($server, $port) = explode(':', $server); + } + + // We can encrypt if we aren't unix socket. + if ($encrypt) { + $server = 'tls://' . $server; + if (empty($configuration['cafile'])) { + $sslopts = [ + 'verify_peer' => false, + 'verify_peer_name' => false, + ]; + } else { + $sslopts = ['cafile' => $configuration['cafile']]; + } + $opts['stream'] = $sslopts; } } try { - if ($redis->connect($server, $port)) { + if ($redis->connect($server, $port, 1, null, 100, 1, $opts)) { + if (!empty($password)) { $redis->auth($password); } @@ -235,11 +253,20 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_ if (!empty($prefix)) { $redis->setOption(Redis::OPT_PREFIX, $prefix); } + if ($encrypt && !$redis->ping()) { + /* + * In case of a TLS connection, if phpredis client does not + * communicate immediately with the server the connection hangs. + * See https://github.com/phpredis/phpredis/issues/2332 . + */ + throw new \RedisException("Ping failed"); + } $this->isready = true; } else { $this->isready = false; } } catch (\RedisException $e) { + debugging("redis $server: $e", DEBUG_NORMAL); $this->isready = false; } @@ -759,6 +786,8 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_ 'password' => $data->password, 'serializer' => $data->serializer, 'compressor' => $data->compressor, + 'encryption' => $data->encryption, + 'cafile' => $data->cafile, ); } @@ -780,6 +809,12 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_ if (!empty($config['compressor'])) { $data['compressor'] = $config['compressor']; } + if (!empty($config['encryption'])) { + $data['encryption'] = $config['encryption']; + } + if (!empty($config['cafile'])) { + $data['cafile'] = $config['cafile']; + } $editform->set_data($data); } @@ -805,6 +840,12 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_ if (!empty($config->test_password)) { $configuration['password'] = $config->test_password; } + if (!empty($config->test_encryption)) { + $configuration['encryption'] = $config->test_encryption; + } + if (!empty($config->test_cafile)) { + $configuration['cafile'] = $config->test_cafile; + } // Make it possible to test TTL performance by hacking a copy of the cache definition. if (!empty($config->test_ttl)) { $definition = clone $definition; @@ -832,6 +873,7 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_ return ['server' => TEST_CACHESTORE_REDIS_TESTSERVERS, 'prefix' => $DB->get_prefix(), + 'encryption' => defined('TEST_CACHESTORE_REDIS_ENCRYPT') && TEST_CACHESTORE_REDIS_ENCRYPT, ]; } diff --git a/cache/stores/redis/settings.php b/cache/stores/redis/settings.php index 71781a82e47..d7e7dfc7c3f 100644 --- a/cache/stores/redis/settings.php +++ b/cache/stores/redis/settings.php @@ -34,6 +34,21 @@ $settings->add( 16 ) ); +$settings->add(new admin_setting_configcheckbox( + 'cachestore_redis/test_encryption', + get_string('encrypt_connection', 'cachestore_redis'), + get_string('encrypt_connection', 'cachestore_redis'), + false)); +$settings->add( + new admin_setting_configtext( + 'cachestore_redis/test_cafile', + get_string('ca_file', 'cachestore_redis'), + get_string('ca_file', 'cachestore_redis'), + '', + PARAM_TEXT, + 16 + ) +); $settings->add( new admin_setting_configpasswordunmask( 'cachestore_redis/test_password', diff --git a/config-dist.php b/config-dist.php index 2cee359e9f1..a1223d3721b 100644 --- a/config-dist.php +++ b/config-dist.php @@ -333,6 +333,9 @@ $CFG->admin = 'admin'; // Redis session handler (requires redis server and redis extension): // $CFG->session_handler_class = '\core\session\redis'; // $CFG->session_redis_host = '127.0.0.1'; +// Use TLS to connect to Redis. An array of SSL context options. Usually: +// $CFG->session_redis_encrypt = ['cafile' => '/path/to/ca.crt']; or... +// $CFG->session_redis_encrypt = ['verify_peer' => false, 'verify_peer_name' => false]; // $CFG->session_redis_port = 6379; // Optional. // $CFG->session_redis_database = 0; // Optional, default is db 0. // $CFG->session_redis_auth = ''; // Optional, default is don't set one. diff --git a/lib/classes/session/redis.php b/lib/classes/session/redis.php index 67116775273..c3b623d78f2 100644 --- a/lib/classes/session/redis.php +++ b/lib/classes/session/redis.php @@ -57,6 +57,8 @@ class redis extends handler { protected $host = ''; /** @var int $port The port to connect to */ protected $port = 6379; + /** @var array $sslopts SSL options, if applicable */ + protected $sslopts = []; /** @var string $auth redis password */ protected $auth = ''; /** @var int $database the Redis database to store sesions in */ @@ -105,6 +107,11 @@ class redis extends handler { $this->port = (int)$CFG->session_redis_port; } + if (isset($CFG->session_redis_encrypt) && $CFG->session_redis_encrypt) { + $this->host = 'tls://' . $this->host; + $this->sslopts = $CFG->session_redis_encrypt; + } + if (isset($CFG->session_redis_auth)) { $this->auth = $CFG->session_redis_auth; } @@ -210,6 +217,11 @@ class redis extends handler { // MDL-59866: Add retries for connections (up to 5 times) to make sure it goes through. $counter = 1; $maxnumberofretries = 5; + $opts = []; + if ($this->sslopts) { + // Do not set $opts['stream'] = [], breaks connect(). + $opts['stream'] = $this->sslopts; + } while ($counter <= $maxnumberofretries) { @@ -218,7 +230,7 @@ class redis extends handler { $delay = rand(100, 500); // One second timeout was chosen as it is long for connection, but short enough for a user to be patient. - if (!$this->connection->connect($this->host, $this->port, 1, null, $delay)) { + if (!$this->connection->connect($this->host, $this->port, 1, null, $delay, 1, $opts)) { throw new RedisException('Unable to connect to host.'); } @@ -238,6 +250,16 @@ class redis extends handler { throw new RedisException('Unable to set Redis Prefix option.'); } } + + if ($this->sslopts && !$this->connection->ping()) { + /* + * In case of a TLS connection, if phpredis client does not + * communicate immediately with the server the connection hangs. + * See https://github.com/phpredis/phpredis/issues/2332 . + */ + throw new \RedisException("Ping failed"); + } + if ($this->database !== 0) { if (!$this->connection->select($this->database)) { throw new RedisException('Unable to select Redis database '.$this->database.'.'); diff --git a/lib/tests/session_redis_test.php b/lib/tests/session_redis_test.php index 29ff632b0bf..b5d9253c5e8 100644 --- a/lib/tests/session_redis_test.php +++ b/lib/tests/session_redis_test.php @@ -29,6 +29,7 @@ use RedisException; * define('TEST_SESSION_REDIS_HOST', '127.0.0.1'); * * @package core + * @covers \core\session\redis * @author Russell Smith * @copyright 2016 Russell Smith * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later @@ -40,6 +41,8 @@ class session_redis_test extends \advanced_testcase { protected $keyprefix = null; /** @var $redis The current testing redis connection */ protected $redis = null; + /** @var bool $encrypted Is the current testing redis connection encrypted*/ + protected $encrypted = false; /** @var int $acquiretimeout how long we wait for session lock in seconds when testing Redis */ protected $acquiretimeout = 1; /** @var int $lockexpire how long to wait in seconds before expiring the lock when testing Redis */ @@ -67,6 +70,19 @@ class session_redis_test extends \advanced_testcase { $this->keyprefix = 'phpunit'.rand(1, 100000); $CFG->session_redis_host = TEST_SESSION_REDIS_HOST; + if (strpos(TEST_SESSION_REDIS_HOST, ':')) { + list($server, $port) = explode(':', TEST_SESSION_REDIS_HOST); + } else { + $server = TEST_SESSION_REDIS_HOST; + $port = 6379; + } + + $opts = []; + if (defined('TEST_SESSION_REDIS_ENCRYPT') && TEST_SESSION_REDIS_ENCRYPT) { + $this->encrypted = true; + $sslopts = $CFG->session_redis_encrypt = ['verify_peer' => false, 'verify_peer_name' => false]; + $opts['stream'] = $sslopts; + } $CFG->session_redis_prefix = $this->keyprefix; // Set a very short lock timeout to ensure tests run quickly. We are running single threaded, @@ -75,7 +91,10 @@ class session_redis_test extends \advanced_testcase { $CFG->session_redis_lock_expire = $this->lockexpire; $this->redis = new Redis(); - $this->redis->connect(TEST_SESSION_REDIS_HOST); + $this->redis->connect($server, $port, 1, null, 1, 0, $opts); + if (!$this->redis->ping()) { + $this->markTestSkipped("Redis ping failed"); + } } public function tearDown(): void { @@ -325,7 +344,11 @@ class session_redis_test extends \advanced_testcase { $actual = $e->getMessage(); } - $expected = 'Failed to connect (try 5 out of 5) to redis at ' . TEST_SESSION_REDIS_HOST . ':111111'; + $host = TEST_SESSION_REDIS_HOST; + if ($this->encrypted) { + $host = "tls://$host"; + } + $expected = "Failed to connect (try 5 out of 5) to redis at $host:111111"; $this->assertDebuggingCalledCount(5); $this->assertStringContainsString($expected, $actual); } @@ -336,4 +359,16 @@ class session_redis_test extends \advanced_testcase { protected function assertSessionNoLocks() { $this->assertEmpty($this->redis->keys($this->keyprefix.'*.lock')); } + + public function test_session_redis_encrypt() { + global $CFG; + + $CFG->session_redis_encrypt = ['verify_peer' => false, 'verify_peer_name' => false]; + + $sess = new \core\session\redis(); + + $prop = new \ReflectionProperty(\core\session\redis::class, 'host'); + $prop->setAccessible(true); + $this->assertEquals('tls://' . TEST_SESSION_REDIS_HOST, $prop->getValue($sess)); + } }