Skip to content

redis: optional TLS for the Redis and Redis Sentinel connections - #2213

Open
kbinetruy wants to merge 13 commits into
sipwise:masterfrom
kbinetruy:redis-tls
Open

kbinetruy wants to merge 13 commits into
sipwise:masterfrom
kbinetruy:redis-tls

Conversation

@kbinetruy

@kbinetruy kbinetruy commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

A Redis server that only accepts TLS (a managed service, or tls-port with port 0) cannot be used today. This was asked on the mailing list in 2022: https://groups.google.com/g/rtpengine/c/gO2hWMKxCNA/m/DNH37Z54BQAJ

This PR depends on #2212, which depends on #2211. The branch has 13 commits; only the last two are new:

  1. MT#55283 redis: optional TLS for the Redis connections (12 files, +550 −5)
  2. MT#55283 redis: TLS for the Redis Sentinel connections (4 files, +87 −8)

The two commits on their own: kbinetruy/rtpengine@redis-sentinel...redis-tls

1. With --redis-cafile or --redis-capath, as for the MQTT options, every Redis connection uses TLS: --redis, --redis-write, --redis-subscribe, the restore copies, and the async connections. --redis-certfile and --redis-keyfile add a client certificate. One SSL_CTX is made at startup. The handshake follows the command timeout. On a reconnect it runs in the thread that makes the attempt, without r->lock. A refused certificate or a handshake timeout is a failed connect, logged with the reason.

Released hiredis (1.2.0 to 1.4.1) checks only the chain, so the name is checked here, in the subjectAltName only. An address needs an IP entry, and no SNI is sent. A host name is checked with X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS, and sent as SNI. --redis-tls-server-name replaces the expected name of every data node.

2. The Sentinel thread uses the same context: a Sentinel's certificate must match its host as given in --redis-sentinel. The ROLE check of a master must match the address that the Sentinels gave, or --redis-tls-server-name.

Option Default Meaning
--redis-cafile=FILE, --redis-capath=PATH unset Either one turns TLS on for every Redis connection.
--redis-certfile=FILE, --redis-keyfile=FILE unset Client certificate. Both or neither.
--redis-tls-server-name=NAME unset Name expected from every data node (and sent as SNI).

TLS 1.2 is the minimum. Files are PEM, and password-protected keys are not supported, as for MQTT. utils/gen-common-flags defines HAVE_HIREDIS_SSL when pkg-config finds hiredis_ssl. On Debian trixie and Ubuntu noble, libhiredis-dev ships it. The TLS options exist only in a build with hiredis_ssl. On other builds (Debian bookworm) they are unknown command-line options, as the MQTT ones, and a redis-cafile key in the configuration file is ignored.

Lab. Everything runs over TLS, with Sentinel, repair and resync and client certificates: failover ×3 and frozen master ×3. Each run had 0 lost, 0 deleted back, and 156 calls restored by a takeover. A reconnect to the new master took 3.4–4.6 ms over TLS, against 4.3 ms over plain TCP.

Testing. t/auto-daemon-tests-redis-tls.pl (126 checks, about 7 s) runs fake Redis and fake Sentinels over IO::Socket::SSL, with certificates made by openssl at test time (libio-socket-ssl-perl added to Build-Depends). It covers every connection type, every refusal (another CA, another address or name, partial wildcard, CN only), SNI, a required client certificate, a silent peer and the startup checks. It fails on the parent commit, and catches mutants of the checks (one equivalent mutant survives). make asan-check on master 3865f896: exit 0 on Debian trixie and Ubuntu noble, 9503 ok, 0 not ok. The patches apply with git am on 3865f896.

Questions:

  1. Should TLS be turned on by --redis-cafile (as for MQTT), or by an explicit --redis-tls switch?
  2. Should the Sentinels share the data nodes' TLS options (as Redis configures both with the same tls-* directives), or have their own?

Lab method, per-run figures and test details: https://github.com/kbinetruy/rtpengine/blob/redis-notes/redis-sentinel-notes.md

Call updates (SET) and deletions (DEL) are pipelined, and
redis_consume() freed their replies unread: a write refused with
READONLY or NOREPLICAS, as during a Sentinel failover, left no trace in
rtpengine.

Check every reply. An error reply is counted in a new statistic,
rediswriteerrors, and logged at most once a second per connection; the
connection is kept, since a reconnect would get the same answer. A read
error or timeout on the replies drops the connection at once instead of
at the next PING. An encoding failure no longer drops the connection.

Tested by auto-daemon-tests-redis-errors.pl, with a new fake Redis
module, NGCP::Rtpengine::FakeRedis.
On a role mismatch, the reconnect in redis_check_conn() sleeps one
second, asks INFO again and sleeps once more: two seconds under
r->lock, which every Redis update holds before the ng reply (sipwise#1865).
After a failover, with --redis on the old master's address, every
update did this, and ng requests timed out.

Make one attempt, with no sleep. After a role failure, or a failure
that took --redis-reconnect-slow (100 ms) or more, skip Redis
operations on that connection for --redis-reconnect-pause (1000 ms),
through restore_tick_us as --redis-allowed-errors does. A quick failure
(connection refused) is retried at once, as before. As with a failed
attempt, a skipped operation is not written; skips are logged once per
pause. The startup connection keeps its eleven attempts.

Tested by auto-daemon-tests-redis-errors.pl.
cli_handle() takes a str, but most "set" handlers parsed their value
with strtol(), strtod() or atoi() on instr->s and printed it with "%s",
reading up to the next NUL. The ng "cli" command passes a slice of the
request: "set maxsessions 1000" set 10007, "set rediscmdtimeout 0" set
7 ms, and error messages quoted the rest of the request.

Parse with the str_to_X helpers, plus a new str_to_d for maxcpu and
maxload, and print with STR_FORMAT. As with the str_to_X family, a
value with trailing characters ("10x") is refused, and so is a
non-number in the seven handlers that read it as 0. "terminate" now
matches all, own and foreign exactly (str_cmp()), not as a prefix.

Tested by auto-daemon-tests-redis-errors.pl and the new t/test-str.c.
The timer that rewrites never-stored SRTP calls can hold a call while it
waits for r->lock. If the call is deleted meanwhile, the deletion,
deciding without the lock that the call was never stored, deletes
nothing, and the timer then writes it: the call is back at the next
restore. Any update racing with a deletion can do the same.

Under r->lock, redis_update_onekey() writes a call only while it is
still in the call table, and redis_do_delete() decides "never stored",
so that a write already past that check is followed by the DEL.

Tested by auto-daemon-tests-redis-destroyed.pl.
Behind Redis Sentinel, rtpengine stays on the address it was given:
after a failover its writes go to the old master, which refuses them
until it is demoted, then serves them as a replica.

New options --redis-sentinel, --redis-sentinel-master and
--redis-sentinel-interval; the keyword "sentinel" replaces IP:PORT in
--redis, --redis-write or --redis-subscribe. A "redis sentinel" thread
asks every Sentinel for the master, takes the highest config epoch
(never an older one than in use), checks the node with ROLE and
publishes it with a generation number. It also runs at once when a
connection meets a replica. redis_check_conn() compares the generation
and reconnects on a change; these connections require the master role.
Without "sentinel", none of this runs.

Tested by auto-daemon-tests-redis-sentinel.pl and -sentinel-start.pl.
A subscription made through "sentinel" stayed on the old master after a
failover, and missed the notifications of the new one.

With Sentinel, the notification event loop gets a one-second timer:
when the master changed, it closes the subscription, and
redis_notify_loop() subscribes again on the new master. The
subscription's context also gets a connect callback, since hiredis
frees a context that fails to connect without calling the disconnect
callback. Notifications missed during the switch are not replayed.

Tested by auto-daemon-tests-redis-sentinel-subscribe.pl.
After a failover the new master refuses writes with NOREPLICAS until it
has a replica. Those writes are counted and logged, but never made
again: a call created then is missing at the next restore, and a call
deleted then comes back.

With --redis-repair-writes (off by default), an own call whose write was
not stored (an error reply, a connection lost before the reply, a
skipped write) is marked, and a stored write clears the mark. A "redis
repair" thread writes the marked calls again, one at a time, through
redis_write_call(), now shared with redis_update_onekey(): a live call
gets its current state, a call that is gone a DEL, unless a new call
uses its ID. Signalling writes waiting for r->lock go first, for 100 ms
at most. A failed write is retried after a second; at most 100000 calls
are kept. New statistic: redisrepairedwrites. A repaired DEL assumes one
writer per database.

Tested by auto-daemon-tests-redis-repair.pl.
Replication is asynchronous, so a master that is replaced takes with it
the writes it acknowledged but had not replicated: a call created then
is missing on the new master, and a call deleted then comes back.

--redis-resync-on-master-change (off by default) keeps the
master_replid of the INFO that redis_connect() already sends. When the
write connection reconnects to another ID, every own call stored at
least once, and the own DELs of the last --redis-resync-delete-window
(at most --redis-resync-delete-max), are queued for the repair thread
of --redis-repair-writes. The write connection must require the master
role. No key is scanned. New statistics: redisresyncs, redisresynccalls
and redisresyncdeletes.

Tested by auto-daemon-tests-redis-resync.pl.
There is no way to reach a Redis server that only accepts TLS (managed
services, or tls-port with port 0); asked on the mailing list in 2022:
https://groups.google.com/g/rtpengine/c/gO2hWMKxCNA/m/DNH37Z54BQAJ

With --redis-cafile or --redis-capath, every Redis connection uses TLS,
and --redis-certfile with --redis-keyfile adds a client certificate. As
for MQTT, the options exist only in a build with hiredis_ssl. hiredis
checks only the chain, so the name is checked here, in the
subjectAltName: the host as configured, the IP address, or
--redis-tls-server-name. The handshake is bounded by the command
timeout, or by the connect timeout when there is none.

Tested by auto-daemon-tests-redis-tls.pl (libio-socket-ssl-perl added to
Build-Depends).
With the TLS options, the Sentinel thread still spoke plain TCP to the
Sentinels and for the role check of the master they name, so a Sentinel
set with tls-port and port 0 could not be used.

sentinel_connect() now starts TLS with the same context and options. A
Sentinel's certificate must match its host as given in
--redis-sentinel; a master's must match the address the Sentinels gave,
or --redis-tls-server-name. The handshake happens in the thread, never
under r->lock.

Tested by auto-daemon-tests-redis-tls.pl.
A reconnect attempt runs under r->lock, which every ng request that
writes a call waits for (sipwise#1865). Against a server that drops packets it
takes the full connect timeout, so signalling stalls for up to that long
at each attempt, for as long as the outage lasts.

Make the attempt without the lock: the thread that reconnects sets
r->reconnecting and owns r->ctx until it clears the flag under the lock.
Other threads wait for it for up to --redis-reconnect-slow, then skip
their operation, as during the pause. After the lock was released,
redis_update_onekey() checks again that the call is live, and
redis_do_delete() leaves the key to a new call with the same ID. The
CLI, the DEL after a failed restore and the notification thread leave a
connection being made alone. A command on an established connection
still holds the lock.

Tested by auto-daemon-tests-redis-reconnect.pl.
redis, redis-json and redis-subscribe compared raw bytes on their own
sockets, and redis-srtp-index and rollback-redis each carried the same
forked server. They now use NGCP::Rtpengine::FakeRedis, and the forked
server moves into it as redis_serve(). The checks, their names and
their counts are unchanged.
--redis-write accepts any role, so after a failover that demotes its
node, rtpengine reconnects to the same address, accepts the replica,
and every later write is refused with READONLY.

--redis-require-master (off by default) makes the connections that
write call data require the master role at connect, and drop the
connection on a write refused with READONLY or MASTERDOWN, so that the
next operation reconnects and checks the role. Other refusals keep the
connection.

Tested by auto-daemon-tests-redis-require-master.pl.
@kbinetruy
kbinetruy force-pushed the redis-tls branch 2 times, most recently from ff6160c to f15fc2d Compare October 20, 2026 09:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant