diff --git a/composer.json b/composer.json
index 9844849..d6da292 100644
--- a/composer.json
+++ b/composer.json
@@ -8,7 +8,7 @@
"license": "BSD-3-Clause",
"require": {
"php": "~8.1.0 || ~8.2.0 || ~8.3.0",
- "ext-redis": "^5.0.2 || ^6.0",
+ "ext-redis": "^5.3.2 || ^6.0",
"laminas/laminas-cache": "^3.10"
},
"provide": {
diff --git a/composer.lock b/composer.lock
index dcfae77..c3afaab 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "18eabbd1db8bc1ba904fad53c3ef3071",
+ "content-hash": "2bad8f0b0911d3df5ac703cd0701dd45",
"packages": [
{
"name": "laminas/laminas-cache",
@@ -5554,7 +5554,7 @@
"prefer-lowest": false,
"platform": {
"php": "~8.1.0 || ~8.2.0 || ~8.3.0",
- "ext-redis": "^5.0.2 || ^6.0"
+ "ext-redis": "^5.3.2 || ^6.0"
},
"platform-dev": [],
"platform-overrides": {
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index e9fb47a..346dd6c 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -139,6 +139,7 @@
setReadTimeout
setSeeds
setTimeout
+ setSslContext
diff --git a/src/RedisClusterOptions.php b/src/RedisClusterOptions.php
index d403cc4..79bbaee 100644
--- a/src/RedisClusterOptions.php
+++ b/src/RedisClusterOptions.php
@@ -6,6 +6,11 @@
use Laminas\Cache\Exception\RuntimeException;
use Laminas\Cache\Storage\Adapter\Exception\InvalidRedisClusterConfigurationException;
+use Laminas\Stdlib\AbstractOptions;
+use Traversable;
+
+use function is_array;
+use function iterator_to_array;
final class RedisClusterOptions extends AdapterOptions
{
@@ -53,6 +58,8 @@ final class RedisClusterOptions extends AdapterOptions
private string $password = '';
+ private ?SslContext $sslContext = null;
+
/**
* @param iterable|null|AdapterOptions $options
* @psalm-param iterable|null|AdapterOptions $options
@@ -76,6 +83,31 @@ public function __construct($options = null)
}
}
+ /**
+ * {@inheritDoc}
+ */
+ public function setFromArray($options)
+ {
+ if ($options instanceof AbstractOptions) {
+ $options = $options->toArray();
+ } elseif ($options instanceof Traversable) {
+ $options = iterator_to_array($options);
+ }
+
+ $sslContext = $options['sslContext'] ?? $options['ssl_context'] ?? null;
+ unset($options['sslContext'], $options['ssl_context']);
+ if (is_array($sslContext)) {
+ /** @psalm-suppress MixedArgumentTypeCoercion Trust upstream that they verify the array beforehand. */
+ $sslContext = SslContext::fromSslContextArray($sslContext);
+ }
+
+ if ($sslContext instanceof SslContext) {
+ $options['ssl_context'] = $sslContext;
+ }
+
+ return parent::setFromArray($options);
+ }
+
public function setTimeout(float $timeout): void
{
$this->timeout = $timeout;
@@ -222,4 +254,14 @@ public function setPassword(string $password): void
{
$this->password = $password;
}
+
+ public function getSslContext(): ?SslContext
+ {
+ return $this->sslContext;
+ }
+
+ public function setSslContext(SslContext|null $sslContext): void
+ {
+ $this->sslContext = $sslContext;
+ }
}
diff --git a/src/RedisClusterResourceManager.php b/src/RedisClusterResourceManager.php
index 7ae5fd2..a9609f4 100644
--- a/src/RedisClusterResourceManager.php
+++ b/src/RedisClusterResourceManager.php
@@ -81,7 +81,8 @@ private function createRedisResource(RedisClusterOptions $options): RedisCluster
$options->getTimeout(),
$options->getReadTimeout(),
$options->isPersistent(),
- $options->getPassword()
+ $options->getPassword(),
+ $options->getSslContext()
);
}
@@ -90,13 +91,20 @@ private function createRedisResource(RedisClusterOptions $options): RedisCluster
$password = null;
}
+ /**
+ * Psalm currently (<= 5.23.1) uses an outdated (phpredis < 5.3.2) constructor signature for the RedisCluster
+ * class in the phpredis extension.
+ *
+ * @psalm-suppress TooManyArguments https://github.com/vimeo/psalm/pull/10862
+ */
return new RedisClusterFromExtension(
null,
$options->getSeeds(),
$options->getTimeout(),
$options->getReadTimeout(),
$options->isPersistent(),
- $password
+ $password,
+ $options->getSslContext()?->toSslContextArray()
);
}
@@ -108,7 +116,8 @@ private function createRedisResourceFromName(
float $fallbackTimeout,
float $fallbackReadTimeout,
bool $persistent,
- string $fallbackPassword
+ string $fallbackPassword,
+ ?SslContext $sslContext
): RedisClusterFromExtension {
$options = new RedisClusterOptionsFromIni();
$seeds = $options->getSeeds($name);
@@ -116,13 +125,20 @@ private function createRedisResourceFromName(
$readTimeout = $options->getReadTimeout($name, $fallbackReadTimeout);
$password = $options->getPasswordByName($name, $fallbackPassword);
+ /**
+ * Psalm currently (<= 5.23.1) uses an outdated (phpredis < 5.3.2) constructor signature for the RedisCluster
+ * class in the phpredis extension.
+ *
+ * @psalm-suppress TooManyArguments https://github.com/vimeo/psalm/pull/10862
+ */
return new RedisClusterFromExtension(
null,
$seeds,
$timeout,
$readTimeout,
$persistent,
- $password
+ $password,
+ $sslContext?->toSslContextArray()
);
}
diff --git a/src/SslContext.php b/src/SslContext.php
new file mode 100644
index 0000000..d9209a2
--- /dev/null
+++ b/src/SslContext.php
@@ -0,0 +1,220 @@
+,
+ * security_level?: non-negative-int
+ * }
+ */
+final class SslContext
+{
+ /**
+ * @param non-empty-string|null $expectedPeerName
+ * @param non-empty-string|null $certificateAuthorityFile
+ * @param non-empty-string|null $certificateAuthorityPath
+ * @param non-empty-string|null $localCertificatePath
+ * @param non-empty-string|null $localPrivateKeyPath
+ * @param non-empty-string|null $passphrase
+ * @param non-negative-int|null $verifyDepth
+ * @param non-empty-string|null $ciphers
+ * @param non-empty-string|array|null $peerFingerprint
+ * @param non-negative-int|null $securityLevel
+ */
+ public function __construct(
+ /**
+ * Peer name to be used.
+ * If this value is not set, then the name is guessed based on the hostname used when opening the stream.
+ */
+ public readonly ?string $expectedPeerName = null,
+ /**
+ * Require verification of SSL certificate used.
+ */
+ public readonly ?bool $verifyPeer = null,
+ /**
+ * Require verification of peer name.
+ */
+ public readonly ?bool $verifyPeerName = null,
+ /**
+ * Allow self-signed certificates. Requires verifyPeer.
+ */
+ public readonly ?bool $allowSelfSignedCertificates = null,
+ /**
+ * Location of Certificate Authority file on local filesystem which should be used with the verifyPeer
+ * context option to authenticate the identity of the remote peer.
+ */
+ public readonly ?string $certificateAuthorityFile = null,
+ /**
+ * If cafile is not specified or if the certificate is not found there, the directory pointed to by capath is
+ * searched for a suitable certificate. capath must be a correctly hashed certificate directory.
+ */
+ public readonly ?string $certificateAuthorityPath = null,
+ /**
+ * Path to local certificate file on filesystem. It must be a PEM encoded file which contains your certificate
+ * and private key. It can optionally contain the certificate chain of issuers.
+ * The private key also may be contained in a separate file specified by localPk.
+ */
+ public readonly ?string $localCertificatePath = null,
+ /**
+ * Path to local private key file on filesystem in case of separate files for certificate (localCert)
+ * and private key.
+ */
+ public readonly ?string $localPrivateKeyPath = null,
+ /**
+ * Passphrase with which your localCert file was encoded.
+ */
+ #[SensitiveParameter]
+ public readonly ?string $passphrase = null,
+ /**
+ * Abort if the certificate chain is too deep.
+ * If not set, defaults to no verification.
+ */
+ public readonly ?int $verifyDepth = null,
+ /**
+ * Sets the list of available ciphers. The format of the string is described in
+ * https://www.openssl.org/docs/manmaster/man1/ciphers.html#CIPHER-LIST-FORMAT
+ */
+ public readonly ?string $ciphers = null,
+ /**
+ * If set to true server name indication will be enabled. Enabling SNI allows multiple certificates on the same
+ * IP address.
+ * If not set, will automatically be enabled if SNI support is available.
+ */
+ public readonly ?bool $serverNameIndicationEnabled = null,
+ /**
+ * If set, disable TLS compression. This can help mitigate the CRIME attack vector.
+ */
+ public readonly ?bool $disableCompression = null,
+ /**
+ * Aborts when the remote certificate digest doesn't match the specified hash.
+ *
+ * When a string is used, the length will determine which hashing algorithm is applied,
+ * either "md5" (32) or "sha1" (40).
+ *
+ * When an array is used, the keys indicate the hashing algorithm name and each corresponding
+ * value is the expected digest.
+ */
+ public readonly array|string|null $peerFingerprint = null,
+ /**
+ * Sets the security level. If not specified the library default security level is used. The security levels are
+ * described in https://www.openssl.org/docs/man1.1.1/man3/SSL_CTX_get_security_level.html.
+ */
+ public readonly ?int $securityLevel = null,
+ ) {
+ }
+
+ /**
+ * @param SSLContextArrayShape $context
+ */
+ public static function fromSslContextArray(array $context): self
+ {
+ return new self(
+ $context['peer_name'] ?? null,
+ $context['verify_peer'] ?? null,
+ $context['verify_peer_name'] ?? null,
+ $context['allow_self_signed'] ?? null,
+ $context['cafile'] ?? null,
+ $context['capath'] ?? null,
+ $context['local_cert'] ?? null,
+ $context['local_pk'] ?? null,
+ $context['passphrase'] ?? null,
+ $context['verify_depth'] ?? null,
+ $context['ciphers'] ?? null,
+ $context['SNI_enabled'] ?? null,
+ $context['disable_compression'] ?? null,
+ $context['peer_fingerprint'] ?? null,
+ $context['security_level'] ?? null,
+ );
+ }
+
+ /**
+ * @return SSLContextArrayShape
+ */
+ public function toSslContextArray(): array
+ {
+ $context = [];
+ if ($this->expectedPeerName !== null) {
+ $context['peer_name'] = $this->expectedPeerName;
+ }
+
+ if ($this->verifyPeer !== null) {
+ $context['verify_peer'] = $this->verifyPeer;
+ }
+
+ if ($this->verifyPeerName !== null) {
+ $context['verify_peer_name'] = $this->verifyPeerName;
+ }
+
+ if ($this->allowSelfSignedCertificates !== null) {
+ $context['allow_self_signed'] = $this->allowSelfSignedCertificates;
+ }
+
+ if ($this->certificateAuthorityFile !== null) {
+ $context['cafile'] = $this->certificateAuthorityFile;
+ }
+
+ if ($this->certificateAuthorityPath !== null) {
+ $context['capath'] = $this->certificateAuthorityPath;
+ }
+
+ if ($this->localCertificatePath !== null) {
+ $context['local_cert'] = $this->localCertificatePath;
+ }
+
+ if ($this->localPrivateKeyPath !== null) {
+ $context['local_pk'] = $this->localPrivateKeyPath;
+ }
+
+ if ($this->passphrase !== null) {
+ $context['passphrase'] = $this->passphrase;
+ }
+
+ if ($this->verifyDepth !== null) {
+ $context['verify_depth'] = $this->verifyDepth;
+ }
+
+ if ($this->ciphers !== null) {
+ $context['ciphers'] = $this->ciphers;
+ }
+
+ if ($this->serverNameIndicationEnabled !== null) {
+ $context['SNI_enabled'] = $this->serverNameIndicationEnabled;
+ }
+
+ if ($this->disableCompression !== null) {
+ $context['disable_compression'] = $this->disableCompression;
+ }
+
+ if ($this->peerFingerprint !== null) {
+ $context['peer_fingerprint'] = $this->peerFingerprint;
+ }
+
+ if ($this->securityLevel !== null) {
+ $context['security_level'] = $this->securityLevel;
+ }
+
+ return $context;
+ }
+}
diff --git a/test/unit/RedisClusterOptionsTest.php b/test/unit/RedisClusterOptionsTest.php
index 18f7d3b..93ae56e 100644
--- a/test/unit/RedisClusterOptionsTest.php
+++ b/test/unit/RedisClusterOptionsTest.php
@@ -9,6 +9,7 @@
use Laminas\Cache\Storage\Adapter\AdapterOptions;
use Laminas\Cache\Storage\Adapter\Exception\InvalidRedisClusterConfigurationException;
use Laminas\Cache\Storage\Adapter\RedisClusterOptions;
+use Laminas\Cache\Storage\Adapter\SslContext;
use Redis as RedisFromExtension;
use ReflectionClass;
@@ -29,6 +30,32 @@ protected function createAdapterOptions(): AdapterOptions
return new RedisClusterOptions(['seeds' => ['localhost']]);
}
+ public function testCanHandleOptionsWithSslContextObject(): void
+ {
+ $options = new RedisClusterOptions([
+ 'name' => 'foo',
+ 'ssl_context' => new SslContext(localCertificatePath: '/path/to/localcert'),
+ ]);
+
+ self::assertEquals('foo', $options->getName());
+ $sslContext = $options->getSslContext();
+ self::assertNotNull($sslContext);
+ self::assertSame('/path/to/localcert', $sslContext->localCertificatePath);
+ }
+
+ public function testCanHandleOptionsWithSslContextArray(): void
+ {
+ $options = new RedisClusterOptions([
+ 'name' => 'foo',
+ 'ssl_context' => ['local_cert' => '/path/to/localcert'],
+ ]);
+
+ self::assertEquals('foo', $options->getName());
+ $sslContext = $options->getSslContext();
+ self::assertNotNull($sslContext);
+ self::assertSame('/path/to/localcert', $sslContext->localCertificatePath);
+ }
+
public function testCanHandleOptionsWithNodename(): void
{
$options = new RedisClusterOptions([
diff --git a/test/unit/SslContextTest.php b/test/unit/SslContextTest.php
new file mode 100644
index 0000000..bd6ed42
--- /dev/null
+++ b/test/unit/SslContextTest.php
@@ -0,0 +1,72 @@
+ 'some peer name',
+ 'verify_peer' => true,
+ 'verify_peer_name' => true,
+ 'allow_self_signed' => true,
+ 'cafile' => '/some/path/to/cafile.pem',
+ 'capath' => '/some/path/to/ca',
+ 'local_cert' => '/some/path/to/local.certificate.pem',
+ 'local_pk' => '/some/path/to/local.key',
+ 'passphrase' => 'secret',
+ 'verify_depth' => 10,
+ 'ciphers' => 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:" .
+"ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:" .
+"DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:" .
+"ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:" .
+"ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:" .
+"DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:" .
+"AES256-GCM-SHA384:AES128:AES256:HIGH:!SSLv2:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!RC4:!ADH',
+ 'SNI_enabled' => true,
+ 'disable_compression' => true,
+ 'peer_fingerprint' => ['md5' => 'some fingerprint'],
+ 'security_level' => 5,
+ ];
+
+ public function testWillNotGenerateContextIfNoneProvided(): void
+ {
+ $context = new SslContext();
+ self::assertSame([], $context->toSslContextArray());
+ }
+
+ public function testSetFromArraySetsPropertiesCorrectly(): void
+ {
+ $context = SslContext::fromSslContextArray(self::SSL_CONTEXT);
+
+ self::assertSame(self::SSL_CONTEXT['peer_name'], $context->expectedPeerName);
+ self::assertSame(self::SSL_CONTEXT['verify_peer'], $context->verifyPeer);
+ self::assertSame(self::SSL_CONTEXT['verify_peer_name'], $context->verifyPeerName);
+ self::assertSame(self::SSL_CONTEXT['allow_self_signed'], $context->allowSelfSignedCertificates);
+ self::assertSame(self::SSL_CONTEXT['cafile'], $context->certificateAuthorityFile);
+ self::assertSame(self::SSL_CONTEXT['capath'], $context->certificateAuthorityPath);
+ self::assertSame(self::SSL_CONTEXT['local_cert'], $context->localCertificatePath);
+ self::assertSame(self::SSL_CONTEXT['local_pk'], $context->localPrivateKeyPath);
+ self::assertSame(self::SSL_CONTEXT['passphrase'], $context->passphrase);
+ self::assertSame(self::SSL_CONTEXT['verify_depth'], $context->verifyDepth);
+ self::assertSame(self::SSL_CONTEXT['ciphers'], $context->ciphers);
+ self::assertSame(self::SSL_CONTEXT['SNI_enabled'], $context->serverNameIndicationEnabled);
+ self::assertSame(self::SSL_CONTEXT['disable_compression'], $context->disableCompression);
+ self::assertSame(self::SSL_CONTEXT['peer_fingerprint'], $context->peerFingerprint);
+ self::assertSame(self::SSL_CONTEXT['security_level'], $context->securityLevel);
+ }
+
+ public function testSetFromArrayThrowsTypeErrorWhenProvidingInvalidValueType(): void
+ {
+ $this->expectException(TypeError::class);
+ $this->expectExceptionMessageMatches('/\(\$verifyPeer\) must be of type \?bool, string given/');
+
+ /** @psalm-suppress InvalidArgument We do want to verify what happens when invalid types are passed. */
+ SslContext::fromSslContextArray(['verify_peer' => 'invalid type']);
+ }
+}