Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
64 changes: 56 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###

Expand Down
4 changes: 3 additions & 1 deletion bin/resque
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
150 changes: 135 additions & 15 deletions src/Resque/Redis.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, bool> Lookup map of all Redis commands that supply a
* key as their first argument, keyed by command name for O(1) lookups.
Expand Down Expand Up @@ -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) {
Expand All @@ -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
*
Expand All @@ -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.
Expand All @@ -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 = [];
Expand All @@ -223,7 +300,7 @@ public static function parseDsn($dsn): array
}

return [
$parts['host'],
$hostPrefix . $parts['host'],
$port,
$database,
$user,
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/Resque/Resque.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

class Resque
{
public const VERSION = '3.3.0';
public const VERSION = '3.4.0';

public const DEFAULT_INTERVAL = 5;

Expand Down Expand Up @@ -66,9 +66,9 @@

if (is_callable(self::$redisServer)) {
self::$redis = call_user_func(self::$redisServer, self::$redisDatabase);
} else {
self::$redis = new \Resque\Redis(self::$redisServer, self::$redisDatabase);
}

Check notice on line 71 in src/Resque/Resque.php

View workflow job for this annotation

GitHub Actions / Linter

no-else-clause

Avoid `else` clauses. >This `else` clause can often be eliminated Code is often clearer when the main logic is not nested inside an `if` statement. Help: Consider refactoring to use an early return (a guard clause) to simplify the control flow.

return self::$redis;
}
Expand Down Expand Up @@ -142,7 +142,7 @@
return false;
}

return json_decode($item, true);

Check warning on line 145 in src/Resque/Resque.php

View workflow job for this annotation

GitHub Actions / Linter

literal-named-argument

Literal argument `true` should be passed as a named argument for clarity. >This literal is being passed positionally. Passing literals positionally can make code less clear, especially with booleans, numbers, or `null`. Help: Consider using a named argument instead: `function_name(param: true)`.
}

/**
Expand All @@ -156,9 +156,9 @@
{
if (count($items) > 0) {
return self::removeItems($queue, $items);
} else {
return self::removeList($queue);
}

Check notice on line 161 in src/Resque/Resque.php

View workflow job for this annotation

GitHub Actions / Linter

no-else-clause

Avoid `else` clauses. >This `else` clause can often be eliminated Code is often clearer when the main logic is not nested inside an `if` statement. Help: Consider refactoring to use an early return (a guard clause) to simplify the control flow.
}

/**
Expand Down Expand Up @@ -206,7 +206,7 @@

return [
'queue' => $queue,
'payload' => json_decode($item[1], true)

Check warning on line 209 in src/Resque/Resque.php

View workflow job for this annotation

GitHub Actions / Linter

literal-named-argument

Literal argument `true` should be passed as a named argument for clarity. >This literal is being passed positionally. Passing literals positionally can make code less clear, especially with booleans, numbers, or `null`. Help: Consider using a named argument instead: `function_name(param: true)`.
];
}

Expand Down Expand Up @@ -305,12 +305,12 @@
if (self::matchItem($string, $items)) {
self::redis()->rpop($tempQueue);
$counter++;
} else {
self::redis()->rpoplpush($tempQueue, self::redis()->getPrefix() . $requeueQueue);
}

Check notice on line 310 in src/Resque/Resque.php

View workflow job for this annotation

GitHub Actions / Linter

no-else-clause

Avoid `else` clauses. >This `else` clause can often be eliminated Code is often clearer when the main logic is not nested inside an `if` statement. Help: Consider refactoring to use an early return (a guard clause) to simplify the control flow.
} else {
$finished = true;
}

Check notice on line 313 in src/Resque/Resque.php

View workflow job for this annotation

GitHub Actions / Linter

no-else-clause

Avoid `else` clauses. >This `else` clause can often be eliminated Code is often clearer when the main logic is not nested inside an `if` statement. Help: Consider refactoring to use an early return (a guard clause) to simplify the control flow.
}

// move back from temp queue to original queue
Expand Down Expand Up @@ -346,27 +346,27 @@
$decoded = json_decode($string, true);

foreach ($items as $key => $val) {
# class name only ex: item[0] = ['class']

Check warning on line 349 in src/Resque/Resque.php

View workflow job for this annotation

GitHub Actions / Linter

no-hash-comment

Shell-style comments ('#') are not allowed. >This is a shell-style comment Help: Consider using double slash comments ('//') instead.
if (is_numeric($key)) {
if ($decoded['class'] == $val) {
return true;
}
# class name with args , example: item[0] = ['class' => {'foo' => 1, 'bar' => 2}]

Check warning on line 354 in src/Resque/Resque.php

View workflow job for this annotation

GitHub Actions / Linter

no-hash-comment

Shell-style comments ('#') are not allowed. >This is a shell-style comment Help: Consider using double slash comments ('//') instead.
} elseif (is_array($val)) {
$decodedArgs = (array)$decoded['args'][0];
if (
$decoded['class'] == $key
&& count($decodedArgs) > 0
&& count(array_diff($decodedArgs, $val)) == 0
) {
return true;
}
# class name with ID, example: item[0] = ['class' => 'id']

Check warning on line 364 in src/Resque/Resque.php

View workflow job for this annotation

GitHub Actions / Linter

no-hash-comment

Shell-style comments ('#') are not allowed. >This is a shell-style comment Help: Consider using double slash comments ('//') instead.
} else {

Check notice on line 365 in src/Resque/Resque.php

View workflow job for this annotation

GitHub Actions / Linter

no-else-clause

Avoid `elseif` clauses. >This `elseif` adds unnecessary complexity Complex conditional chains can often be simplified by using early returns or a `match` expression. Help: Refactor to use guard clauses (early returns) or a `match` expression for clarity.
if ($decoded['class'] == $key && $decoded['id'] == $val) {
return true;
}
}

Check notice on line 369 in src/Resque/Resque.php

View workflow job for this annotation

GitHub Actions / Linter

no-else-clause

Avoid `else` clauses. >This `else` clause can often be eliminated Code is often clearer when the main logic is not nested inside an `if` statement. Help: Consider refactoring to use an early return (a guard clause) to simplify the control flow.
}
return false;
}
Expand Down
Loading
Loading