diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4547c96..0146585 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,14 +18,23 @@ jobs: PHPTest: runs-on: ubuntu-latest container: php:${{ matrix.php_version }} + name: PHPTest (PHP ${{ matrix.php_version }}, ${{ matrix.backend.name }}) strategy: + fail-fast: false matrix: php_version: [8.3, 8.4, 8.5] + backend: + - name: redis + image: redis:8.4 + health_cmd: redis-cli ping + - name: valkey + image: valkey/valkey:9.1 + health_cmd: valkey-cli ping services: redis: - image: redis:8.4 + image: ${{ matrix.backend.image }} options: >- - --health-cmd "redis-cli ping" + --health-cmd "${{ matrix.backend.health_cmd }}" --health-interval 10s --health-timeout 5s --health-retries 5 @@ -35,5 +44,5 @@ jobs: - name: Install composer run: apt-get update -yq && apt-get install git wget procps unzip -y && pecl install -o -f redis && rm -rf /tmp/pear && docker-php-ext-enable redis && wget https://getcomposer.org/composer.phar && php composer.phar install --dev - - name: Run PHP ${{ matrix.php_version }} Unit Tests + - name: Run PHP ${{ matrix.php_version }} Unit Tests against ${{ matrix.backend.image }} run: php vendor/bin/phpunit --configuration phpunit.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index dce5a0d..742233e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 3.4.0 (2026-08-03) +- Add TLS support to the Redis DSN via the `rediss://`, `tls://` and `ssl://` schemes +- Add `tls_`-prefixed DSN options (`tls_cafile`, `tls_verify_peer`, `tls_local_cert`, ...) mapping to PHP SSL context options; unknown `tls_` options are rejected +- Add Redis 6+ ACL support: a DSN username is now sent as `AUTH user pass` when a password is also supplied +- Percent-decode the DSN username and password so credentials may contain reserved characters +- Hand credentials to the driver instead of calling `AUTH` at construction, so they are replayed on reconnect (a bad password now surfaces on first command rather than at construction) +- Run CI against Valkey 9.1 as well as Redis 8.4 + # 3.3.0 (2026-07-19) - Add declare(strict_types=1) to all PHP files - Add mago as a dev dependency (pinned to ~1.44.0) and a mago.toml config diff --git a/README.md b/README.md index f48f9fa..c0b16ad 100644 --- a/README.md +++ b/README.md @@ -273,14 +273,62 @@ redis://user:pass@host:port/db?option1=val1&option2=val2 Notes: -- The `user` portion is required by the URI syntax but is **not** used — only - the password is applied via Redis `AUTH`. Use `redis://:password@host` (empty - user) when you only need a password. -- Always run Redis with authentication (`requirepass`) enabled and restrict - network access in production. php-resque connects without a password if none - is supplied, so an unprotected Redis is reachable by anything that can route - to it. -- Supported schemes are `redis`, `tcp`, and `unix://` (for a socket path). +- Supply just a password (`redis://:password@host`) to authenticate as the + default user with `AUTH password` — this is what `requirepass` expects. +- Supply both parts (`redis://user:password@host`) to authenticate a Redis 6+ + ACL user with `AUTH user password`. A username on its own is ignored. +- The username and password are percent-decoded, so credentials containing + reserved characters must be encoded: `p@ss:word` becomes `p%40ss%3Aword`. +- Credentials are held by the driver and replayed automatically if the + connection drops and is re-established. +- Always run Redis with authentication enabled and restrict network access in + production. php-resque connects without a password if none is supplied, so an + unprotected Redis is reachable by anything that can route to it. +- Supported schemes are `redis`, `tcp`, `rediss`, `tls`, `ssl`, and `unix://` + (for a socket path). + +#### TLS #### + +Use the `rediss://` scheme (or `tls://` / `ssl://`) to connect over an +encrypted channel — required by managed services such as AWS ElastiCache with +in-transit encryption enabled: + +```sh +$ REDIS_BACKEND=rediss://resque:my-secret-password@redis.internal:6379 bin/resque +``` + +The certificate chain is verified against the system CA bundle by default. TLS +behaviour is tuned with `tls_`-prefixed DSN options, each of which maps to the +[PHP SSL context option](https://www.php.net/manual/en/context.ssl.php) of the +same name with the prefix removed: + +| Option | Purpose | +| --- | --- | +| `tls_cafile`, `tls_capath` | Verify the server against a private CA | +| `tls_local_cert`, `tls_local_pk`, `tls_passphrase` | Present a client certificate (mutual TLS) | +| `tls_peer_name` | Expected certificate name, when it differs from the connection host | +| `tls_verify_peer`, `tls_verify_peer_name`, `tls_allow_self_signed` | Relax verification (development only) | +| `tls_ciphers`, `tls_disable_compression` | Cipher and compression control | + +```sh +# Verify against a private CA +$ REDIS_BACKEND='rediss://:pass@redis.internal?tls_cafile=/etc/ssl/redis-ca.pem' bin/resque + +# Self-signed certificate — do not use outside development +$ REDIS_BACKEND='rediss://:pass@redis.internal?tls_verify_peer=0&tls_verify_peer_name=0&tls_allow_self_signed=1' bin/resque +``` + +Boolean options accept `0`, `false`, `off`, `no` or an empty value as false and +anything else as true. An unrecognised `tls_` option is rejected rather than +ignored, so a typo cannot silently leave verification disabled. + +Note on ACL users: when the `redis` PHP extension is not installed (or is older +than 5.3.0) php-resque falls back to Credis' pure-PHP client. On one of that +client's reconnect paths — a command issued after the server has closed an idle +connection — it re-sends `AUTH` with the password only, dropping the username. +Against an ACL user that authenticates as the default user instead, or fails +outright. If you use `user:password` credentials, install the `redis` extension +(5.3.0 or newer) so the extension's own connection handling is used. ### Forking ### diff --git a/bin/resque b/bin/resque index 6720bea..b6f70a1 100644 --- a/bin/resque +++ b/bin/resque @@ -35,8 +35,10 @@ if (empty($QUEUE)) { /** * REDIS_BACKEND can have simple 'host:port' format or use a DSN-style format like this: * - redis://user:pass@host:port + * - rediss://user:pass@host:port?tls_cafile=/etc/ssl/redis-ca.pem (TLS) * - * Note: the 'user' part of the DSN URI is required but is not used. + * Note: 'user' is only used alongside a password, as a Redis 6+ ACL `AUTH user pass`. + * See the README for the supported schemes and TLS options. */ $REDIS_BACKEND = getenv('REDIS_BACKEND'); diff --git a/src/Resque/Redis.php b/src/Resque/Redis.php index 674779d..36c510f 100644 --- a/src/Resque/Redis.php +++ b/src/Resque/Redis.php @@ -48,6 +48,52 @@ class Redis */ public const DEFAULT_REDIS_TTL = 172800; + /** + * DSN schemes that connect in the clear + */ + private const PLAINTEXT_SCHEMES = ['redis', 'tcp']; + + /** + * DSN schemes that connect over TLS, mapped to the transport prefix Credis expects + */ + private const TLS_SCHEMES = [ + 'rediss' => 'tls://', + 'tls' => 'tls://', + 'ssl' => 'ssl://', + ]; + + /** + * Boolean `tls_`-prefixed DSN options, passed through as PHP SSL context options + * + * @see https://www.php.net/manual/en/context.ssl.php + */ + private const TLS_BOOL_OPTIONS = [ + 'verify_peer', + 'verify_peer_name', + 'allow_self_signed', + 'disable_compression', + ]; + + /** + * String `tls_`-prefixed DSN options, passed through as PHP SSL context options + * + * @see https://www.php.net/manual/en/context.ssl.php + */ + private const TLS_STRING_OPTIONS = [ + 'cafile', + 'capath', + 'local_cert', + 'local_pk', + 'passphrase', + 'peer_name', + 'ciphers', + ]; + + /** + * Values treated as `false` for a boolean DSN option + */ + private const FALSEY_OPTION_VALUES = ['0', 'false', 'off', 'no', '']; + /** * @var array Lookup map of all Redis commands that supply a * key as their first argument, keyed by command name for O(1) lookups. @@ -131,17 +177,28 @@ public function __construct($server, $database = null, $client = null) if (is_object($client)) { $this->driver = $client; } else { - /** @noinspection PhpUnusedLocalVariableInspection */ list($host, $port, $dsnDatabase, $user, $password, $options) = self::parseDsn($server); - // $user is not used, only $password + $options = is_array($options) ? $options : []; $timeout = isset($options['timeout']) ? intval($options['timeout']) : null; $persistent = isset($options['persistent']) ? $options['persistent'] : ''; $maxRetries = isset($options['max_connect_retries']) ? $options['max_connect_retries'] : 0; - $this->driver = new \Credis_Client($host, $port, $timeout, $persistent); + $tlsOptions = self::parseTlsOptions($options); + // Credentials are handed to the driver rather than AUTH'd here so that they + // are replayed if the connection drops and Credis reconnects. A username is + // only meaningful alongside a password (Redis 6+ ACL `AUTH user pass`). + $password = ($password === false || $password === null || $password === '') ? null : $password; + $user = ($password === null || $user === false || $user === null || $user === '') ? null : $user; + $this->driver = new \Credis_Client( + $host, + $port, + $timeout, + $persistent, + 0, + $password, + $user, + $tlsOptions === [] ? null : $tlsOptions + ); $this->driver->setMaxConnectRetries($maxRetries); - if ($password) { - $this->driver->auth($password); - } // If we have found a database in our DSN, use it instead of the `$database` // value passed into the constructor. if ($dsnDatabase !== false) { @@ -162,9 +219,16 @@ public function __construct($server, $database = null, $client = null) * - host:port * - redis://user:pass@host:port/db?option1=val1&option2=val2 * - tcp://user:pass@host:port/db?option1=val1&option2=val2 + * - rediss://user:pass@host:port/db?option1=val1&option2=val2 (TLS) + * - tls://user:pass@host:port/db (TLS, as does ssl://) * - unix:///path/to/redis.sock * - * Note: the 'user' part of the DSN is not used. + * The 'user' part is only used when a password is also supplied, in which case it is + * sent as a Redis 6+ ACL `AUTH user pass`. Both are percent-decoded, so credentials + * containing reserved characters such as `@`, `:` or `/` must be percent-encoded. + * + * For a TLS scheme the returned host keeps its transport prefix (e.g. `tls://redis.internal`) + * as that is how the underlying Credis driver is told to negotiate an encrypted connection. * * @param string $dsn A DSN string * @@ -189,10 +253,22 @@ public static function parseDsn($dsn): array } $parts = parse_url($dsn); - // Check the URI scheme - $validSchemes = ['redis', 'tcp']; - if (isset($parts['scheme']) && !in_array($parts['scheme'], $validSchemes)) { - throw new \InvalidArgumentException("Invalid DSN. Supported schemes are " . implode(', ', $validSchemes)); + // Check the URI scheme, and work out whether the connection should be encrypted + $scheme = isset($parts['scheme']) ? strtolower($parts['scheme']) : 'redis'; + $hostPrefix = ''; + if (!in_array($scheme, self::PLAINTEXT_SCHEMES, strict: true)) { + if (!isset(self::TLS_SCHEMES[$scheme])) { + $validSchemes = array_merge( + self::PLAINTEXT_SCHEMES, + array_keys(self::TLS_SCHEMES), + ['unix'] + ); + throw new \InvalidArgumentException( + "Invalid DSN. Supported schemes are " . implode(', ', $validSchemes) + ); + } + + $hostPrefix = self::TLS_SCHEMES[$scheme]; } // Allow simple 'hostname' format, which `parse_url` treats as a path, not host. @@ -211,9 +287,10 @@ public static function parseDsn($dsn): array $database = intval(preg_replace('/[^0-9]/', '', $parts['path'])); } - // Extract any 'user' and 'pass' values - $user = isset($parts['user']) ? $parts['user'] : false; - $pass = isset($parts['pass']) ? $parts['pass'] : false; + // Extract any 'user' and 'pass' values, undoing any percent-encoding needed to + // carry reserved characters through the URI + $user = isset($parts['user']) ? rawurldecode($parts['user']) : false; + $pass = isset($parts['pass']) ? rawurldecode($parts['pass']) : false; // Convert the query string into an associative array $options = []; @@ -223,7 +300,7 @@ public static function parseDsn($dsn): array } return [ - $parts['host'], + $hostPrefix . $parts['host'], $port, $database, $user, @@ -232,6 +309,49 @@ public static function parseDsn($dsn): array ]; } + /** + * Pull the TLS context options out of the parsed DSN query options. + * + * Any `tls_`-prefixed option is treated as a PHP SSL context option of the same name + * with the prefix removed, e.g. `?tls_cafile=/etc/ssl/redis-ca.pem&tls_verify_peer=0`. + * Unrecognised `tls_` options are rejected rather than silently ignored, so that a + * typo cannot quietly leave verification in a state you did not ask for. + * + * @param array $options The options array returned by {@see self::parseDsn()} + * + * @return array PHP SSL context options, empty when the DSN sets none + * + * @see https://www.php.net/manual/en/context.ssl.php + */ + public static function parseTlsOptions(array $options): array + { + $tlsOptions = []; + foreach ($options as $key => $value) { + $key = (string)$key; + if (!str_starts_with($key, 'tls_')) { + continue; + } + + $name = substr($key, 4); + if (in_array($name, self::TLS_BOOL_OPTIONS, strict: true)) { + $tlsOptions[$name] = !in_array( + strtolower((string)$value), + self::FALSEY_OPTION_VALUES, + strict: true + ); + continue; + } + + if (!in_array($name, self::TLS_STRING_OPTIONS, strict: true)) { + throw new \InvalidArgumentException("Invalid DSN. Unknown TLS option '" . $key . "'"); + } + + $tlsOptions[$name] = (string)$value; + } + + return $tlsOptions; + } + /** * Magic method to handle all function requests and prefix key based * operations with the {self::$defaultNamespace} key prefix. diff --git a/src/Resque/Resque.php b/src/Resque/Resque.php index 899bd6e..f990854 100644 --- a/src/Resque/Resque.php +++ b/src/Resque/Resque.php @@ -14,7 +14,7 @@ class Resque { - public const VERSION = '3.3.0'; + public const VERSION = '3.4.0'; public const DEFAULT_INTERVAL = 5; diff --git a/tests/Resque/Tests/RedisTest.php b/tests/Resque/Tests/RedisTest.php index 80b0b36..2a657e9 100644 --- a/tests/Resque/Tests/RedisTest.php +++ b/tests/Resque/Tests/RedisTest.php @@ -268,6 +268,87 @@ public static function validDsnStringProvider() null, ], ], + // TLS schemes keep their transport prefix on the host for the driver + [ + 'rediss://foobar', + [ + 'tls://foobar', + \Resque\Redis::DEFAULT_PORT, + false, + false, + false, + [], + ], + ], + [ + 'rediss://user:pass@foobar:1234/2?x=y', + [ + 'tls://foobar', + 1234, + 2, + 'user', + 'pass', + ['x' => 'y'], + ], + ], + [ + 'tls://user:pass@foobar:1234', + [ + 'tls://foobar', + 1234, + false, + 'user', + 'pass', + [], + ], + ], + [ + 'ssl://foobar:1234', + [ + 'ssl://foobar', + 1234, + false, + false, + false, + [], + ], + ], + // Schemes are matched case-insensitively + [ + 'REDISS://foobar:1234', + [ + 'tls://foobar', + 1234, + false, + false, + false, + [], + ], + ], + // Credentials are percent-decoded + [ + 'redis://us%40er:p%40ss%3Aword@foobar:1234', + [ + 'foobar', + 1234, + false, + 'us@er', + 'p@ss:word', + [], + ], + ], + // TLS options are left in the options array for parseTlsOptions() to pick up + [ + 'rediss://foobar:1234?tls_cafile=/etc/ssl/ca.pem&tls_verify_peer=0', + [ + 'tls://foobar', + 1234, + false, + false, + false, + ['tls_cafile' => '/etc/ssl/ca.pem', 'tls_verify_peer' => '0'], + ], + ], ]; } @@ -281,6 +362,8 @@ public static function bogusDsnStringProvider() ['http://foo.bar/'], ['user:@foobar:1234?x=y&a=b'], ['foobar:1234?x=y&a=b'], + ['redis+tls://foobar:1234'], + ['https://foobar:1234'], ]; } @@ -297,4 +380,150 @@ public function testParsingBogusDsnStringThrowsException($dsn) $this->expectException(\InvalidArgumentException::class); \Resque\Redis::parseDsn($dsn); } + + public function testParseTlsOptionsIgnoresNonTlsOptions() + { + static::assertEquals( + [], + \Resque\Redis::parseTlsOptions(['timeout' => '5', 'persistent' => 'x', 'max_connect_retries' => '3']) + ); + } + + public function testParseTlsOptionsCastsBooleanAndStringOptions() + { + static::assertEquals( + [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + 'cafile' => '/etc/ssl/redis-ca.pem', + 'peer_name' => 'redis.internal', + ], + \Resque\Redis::parseTlsOptions([ + 'tls_verify_peer' => '0', + 'tls_verify_peer_name' => 'false', + 'tls_allow_self_signed' => '1', + 'tls_cafile' => '/etc/ssl/redis-ca.pem', + 'tls_peer_name' => 'redis.internal', + ]) + ); + } + + /** + * @return array + */ + public static function falseyTlsOptionValueProvider() + { + return [['0'], ['false'], ['FALSE'], ['off'], ['no'], ['']]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('falseyTlsOptionValueProvider')] + public function testParseTlsOptionsTreatsFalseyValuesAsDisabled($value) + { + static::assertEquals( + ['verify_peer' => false], + \Resque\Redis::parseTlsOptions(['tls_verify_peer' => $value]) + ); + } + + public function testParseTlsOptionsRejectsUnknownTlsOption() + { + $this->expectException(\InvalidArgumentException::class); + \Resque\Redis::parseTlsOptions(['tls_verify_pear' => '0']); + } + + public function testTlsDsnBuildsAnEncryptedDriver() + { + $driver = self::driverForDsn('rediss://redis.internal:6380'); + + static::assertTrue($driver->isTls()); + static::assertEquals('redis.internal', $driver->getHost()); + static::assertEquals(6380, $driver->getPort()); + } + + public function testPlaintextDsnBuildsAnUnencryptedDriver() + { + $driver = self::driverForDsn('redis://redis.internal:6380'); + + static::assertFalse($driver->isTls()); + static::assertEquals('redis.internal', $driver->getHost()); + } + + public function testTlsOptionsFromDsnArePassedToTheDriver() + { + $driver = self::driverForDsn( + 'rediss://redis.internal?tls_cafile=/etc/ssl/redis-ca.pem&tls_verify_peer=0' + ); + + static::assertEquals( + ['cafile' => '/etc/ssl/redis-ca.pem', 'verify_peer' => false], + self::driverProperty($driver, 'tlsOptions') + ); + } + + public function testPasswordOnlyDsnAuthenticatesWithoutAUsername() + { + $driver = self::driverForDsn('redis://:my-secret@redis.internal'); + + static::assertEquals('my-secret', self::driverProperty($driver, 'authPassword')); + static::assertNull(self::driverProperty($driver, 'authUsername')); + } + + public function testUsernameAndPasswordDsnAuthenticatesWithAclCredentials() + { + $driver = self::driverForDsn('redis://resque:my-secret@redis.internal'); + + static::assertEquals('my-secret', self::driverProperty($driver, 'authPassword')); + static::assertEquals('resque', self::driverProperty($driver, 'authUsername')); + } + + public function testPercentEncodedCredentialsAreDecodedForAuth() + { + $driver = self::driverForDsn('redis://us%40er:p%40ss%3Aword@redis.internal'); + + static::assertEquals('p@ss:word', self::driverProperty($driver, 'authPassword')); + static::assertEquals('us@er', self::driverProperty($driver, 'authUsername')); + } + + public function testUsernameWithoutAPasswordIsNotUsedForAuth() + { + $driver = self::driverForDsn('redis://resque@redis.internal'); + + static::assertNull(self::driverProperty($driver, 'authPassword')); + static::assertNull(self::driverProperty($driver, 'authUsername')); + } + + /** + * Build a \Resque\Redis from a DSN and return its underlying driver. + * + * No database is passed, so the driver is configured but never connected — these + * assertions do not need (or reach) a real Redis server. + * + * @param string $dsn + * + * @return \Credis_Client + */ + private static function driverForDsn($dsn) + { + $property = new \ReflectionProperty(\Resque\Redis::class, 'driver'); + $property->setAccessible(true); + + return $property->getValue(new \Resque\Redis($dsn)); + } + + /** + * Read a protected \Credis_Client property that has no public accessor. + * + * @param \Credis_Client $driver + * @param string $name + * + * @return mixed + */ + private static function driverProperty($driver, $name) + { + $property = new \ReflectionProperty(\Credis_Client::class, $name); + $property->setAccessible(true); + + return $property->getValue($driver); + } }