Skip to content

Commit 2d134a3

Browse files
committed
feat(tls): serve TLS, and announce the port that serves it
Redis serves TLS by setting `port 0` and `tls-port <n>`, so a TLS broker announced `<ip>:0` — an address nothing can connect to, and one that @imqueue's UDP listener discards as malformed. The fleet discovered no broker at all and no log said why. Both announcers now advertise whichever listener is up and mark the datagram `tls` or `plain` (submodule bump), and this image composes the listeners and the announcement together, in one place. Mounting IMQ_TLS_CERT_FILE and IMQ_TLS_KEY_FILE is the whole switch. The TLS listener takes 6379 — the port the Service, the NetworkPolicy and the probes already name — so turning TLS on costs no manifest churn; it moves to 6380 only when IMQ_TLS_PLAINTEXT keeps cleartext alive alongside it. Client certificates are required by default: encryption without them leaves the broker open to anyone who can reach the port, and discovery is unauthenticated. Refused before Redis starts, because every one of these is otherwise silent: a certificate without its key, mTLS with no CA to check against, a path that is not in the container, a key the redis user (uid 999) cannot read after privileges are dropped, REDIS_BROADCAST_TLS demanding a listener that does not exist, --tls-*/--port on the command line splitting a decision this image composes, and a redis-server built without TLS support, which dies on `tls-port` rather than ignoring it. 17 new smoke assertions, green on Redis 6.2, 7.2 and 7.4 — including the announcement captured off the wire and a stand-in for a build without TLS.
1 parent f775b0e commit 2d134a3

9 files changed

Lines changed: 557 additions & 16 deletions

File tree

Dockerfile

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,12 @@ COPY entrypoint.sh /usr/local/bin/redis-broker-entrypoint.sh
6565
RUN chmod 0755 /usr/local/bin/redis-broker-entrypoint.sh; \
6666
mkdir -p /etc/redis
6767

68-
# 6379 is Redis. 63000/udp is where the announcer shouts, and where a service's
69-
# UDPClusterManager listens. Both are needed for discovery to work.
70-
EXPOSE 6379 63000/udp
68+
# 6379 is Redis — cleartext, or TLS in its place once IMQ_TLS_* is configured, so
69+
# turning TLS on moves no ports. 6380 is where the TLS listener goes when the
70+
# cleartext one stays up (IMQ_TLS_PLAINTEXT=on). 63000/udp is where the announcer
71+
# shouts, and where a service's UDPClusterManager listens. The announcer and the
72+
# port it announces are both needed for discovery to work.
73+
EXPOSE 6379 6380 63000/udp
7174

7275
# Wraps the official entrypoint rather than replacing it: that script does uid
7376
# handling and argument rewriting this image has no business reimplementing.

README.md

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,21 @@ Environment variables compose a real `redis.conf`. Run with
5454
| `IMQ_REQUIREPASS_FILE` || Read the password from a file, so it stays out of `docker inspect` and the process environment |
5555
| `IMQ_ACL` | `auto` | `auto` \| `on` \| `off` — see *The config lock* |
5656
| `IMQ_MAXMEMORY` || `maxmemory`. The policy is always `noeviction` |
57+
| `IMQ_TLS` | `auto` | `auto` \| `on` \| `off`. `auto` turns TLS on as soon as a certificate is mounted |
58+
| `IMQ_TLS_CERT_FILE` || This broker's certificate |
59+
| `IMQ_TLS_KEY_FILE` || Its private key. Both are required together |
60+
| `IMQ_TLS_CA_FILE` || The CA whose **client** certificates this broker accepts |
61+
| `IMQ_TLS_KEY_PASSPHRASE_FILE` || Passphrase for an encrypted key, read from a file |
62+
| `IMQ_TLS_AUTH_CLIENTS` | `yes` | `yes` \| `no` \| `optional`. `yes` is mTLS and needs `IMQ_TLS_CA_FILE` |
63+
| `IMQ_TLS_PORT` | `6379` | `6380` when the plaintext listener stays up |
64+
| `IMQ_TLS_PLAINTEXT` | `off` | Keep the cleartext listener alongside TLS |
5765
| `IMQ_REDIS_CONF` || Path to your own config, included first so everything above overrides it |
5866
| `IMQ_PRINT_CONFIG` | `0` | Print the composed config and exit without starting Redis |
5967

6068
The announcer modules read their own variables directly: `REDIS_BROADCAST_NAME`
6169
(default `imq-broker`), `REDIS_BROADCAST_PORT` (`63000`),
62-
`REDIS_BROADCAST_INTERVAL` (`1`), and — unicaster only — `SELECTED_INTERFACES`
63-
and `DEPLOYMENT_ENV`.
70+
`REDIS_BROADCAST_INTERVAL` (`1`), `REDIS_BROADCAST_TLS` (unset — see *TLS*), and
71+
— unicaster only — `SELECTED_INTERFACES` and `DEPLOYMENT_ENV`.
6472

6573
> **`DEPLOYMENT_ENV` is the Kubernetes namespace**, despite the name. It is
6674
> interpolated into `/api/v1/namespaces/<value>/pods`, so a value like
@@ -137,6 +145,122 @@ like a network fault.
137145
Auth protects the data path only. It does not authenticate discovery; see
138146
[THREAT-MODEL.md](./THREAT-MODEL.md).
139147

148+
### TLS
149+
150+
Mount a certificate and a key, and the broker serves TLS. Nothing else changes:
151+
152+
```bash
153+
docker run -v /path/to/tls:/run/tls:ro \
154+
-e IMQ_TLS_CERT_FILE=/run/tls/broker.crt \
155+
-e IMQ_TLS_KEY_FILE=/run/tls/broker.key \
156+
-e IMQ_TLS_CA_FILE=/run/tls/ca.crt \
157+
ghcr.io/imqueue/redis-broker:7.4
158+
```
159+
160+
Three consequences, and they are the whole design:
161+
162+
**The cleartext listener goes away.** Redis serves TLS by setting `port 0` and
163+
`tls-port <n>` — there is no "both" unless you ask for it with
164+
`IMQ_TLS_PLAINTEXT=on`.
165+
166+
**The TLS port is 6379**, the port your Service, NetworkPolicy, probes and
167+
runbooks already name, so turning TLS on is one variable and no manifest churn.
168+
It becomes 6380 only when the plaintext listener stays up, because two listeners
169+
cannot share a port.
170+
171+
**The announcement follows the listener.** `port 0` is how Redis is told to stop
172+
listening in cleartext, and the announcers used to advertise `port` verbatim — so
173+
a TLS broker announced `<ip>:0`, an address nothing can connect to, and one that
174+
`UDPClusterManager` discards as malformed. The fleet discovered no broker at all
175+
and no log said why. The modules now announce whichever listener is up, and mark
176+
the datagram `tls` or `plain`. When both are up, plaintext is announced, because
177+
that is what an already-running fleet is connected to; `REDIS_BROADCAST_TLS=1`
178+
picks the TLS port instead.
179+
180+
Whatever you ask for, the container refuses to start rather than announcing a
181+
port that is not listening or serving cleartext where TLS was requested.
182+
183+
#### Certificates, when the broker's address is not knowable in advance
184+
185+
A broker gets its IP from the scheduler and announces it. Nothing can issue a
186+
certificate for that address ahead of time, and there is no name to use either —
187+
the fleet is found by announcement, not by DNS. So issue **one certificate for
188+
the fleet**, carrying a name that will never be resolved, and have clients pin
189+
that name:
190+
191+
```bash
192+
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \
193+
-subj /CN=imq-broker-ca -keyout ca.key -out ca.crt
194+
195+
openssl req -newkey rsa:2048 -nodes -subj /CN=imq-broker.internal \
196+
-keyout broker.key -out broker.csr
197+
openssl x509 -req -in broker.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
198+
-days 365 -extfile <(printf 'subjectAltName=DNS:imq-broker.internal') \
199+
-out broker.crt
200+
```
201+
202+
The service side, in `@imqueue/core`:
203+
204+
```bash
205+
IMQ_REDIS_TLS_CA_FILE=/run/tls/ca.crt
206+
IMQ_REDIS_TLS_SERVERNAME=imq-broker.internal
207+
IMQ_REDIS_TLS_CERT_FILE=/run/tls/client.crt # when IMQ_TLS_AUTH_CLIENTS=yes
208+
IMQ_REDIS_TLS_KEY_FILE=/run/tls/client.key
209+
```
210+
211+
`servername` is not a hostname to connect to: Node compares it against the
212+
certificate and never resolves it, while the connection still goes to the
213+
announced IP. That is what decouples certificate identity from an address the
214+
scheduler owns — and it means a broker pod that dies and comes back on a
215+
different IP needs no new certificate.
216+
217+
#### Client certificates
218+
219+
`IMQ_TLS_AUTH_CLIENTS` is `yes` by default: every client must present a
220+
certificate signed by `IMQ_TLS_CA_FILE`. Encryption without it leaves the broker
221+
open to anyone who can reach the port, which — given the discovery channel is
222+
unauthenticated — is the half of the problem worth keeping. `no` encrypts
223+
without authenticating; `optional` accepts both and is a migration state, not a
224+
destination.
225+
226+
#### Rotating certificates
227+
228+
Both halves of the fleet read their material once, at start. Rotation is
229+
therefore a rolling restart, and the CAs have to overlap: distribute a CA bundle
230+
containing old and new, roll the brokers onto the new certificate, roll the
231+
services, then drop the old CA from the bundle.
232+
233+
#### Turning it on for a fleet that is already running
234+
235+
The announcement carries **one** transport for the whole fleet, so this is a
236+
cutover rather than an overlap: while the announcement says `plain`, a
237+
TLS-configured service cannot use it, and vice versa. Keep the window short:
238+
239+
1. Brokers: certificates plus `IMQ_TLS_PLAINTEXT=on`. Both listeners are up, the
240+
announcement is unchanged, and nothing in the fleet notices. Verify by hand
241+
with `redis-cli --tls --cacert ca.crt --sni imq-broker.internal -p 6380 PING`.
242+
2. Roll the services with their TLS options, and the brokers with
243+
`REDIS_BROADCAST_TLS=1`, together. Services reconnect over TLS as their
244+
discovery refreshes.
245+
3. Drop `IMQ_TLS_PLAINTEXT` and `REDIS_BROADCAST_TLS`. The TLS listener moves
246+
back to 6379 and is the only one left.
247+
248+
The `tls`/`plain` marker on the datagram exists so that a client could one day
249+
choose per broker and make step 2 a rolling change; `@imqueue/core` does not read
250+
it today, and reading it would mean letting an unauthenticated datagram decide
251+
whether to encrypt — which is a decision that needs more than a UDP packet
252+
behind it.
253+
254+
#### What TLS here does and does not cover
255+
256+
It encrypts and authenticates the **data path** — the connection between a
257+
service and a broker. It does not authenticate **discovery**: the datagram is
258+
still unsigned, and a hostile one can still name any address. What changes is
259+
what an attacker gains by it: with `IMQ_TLS_AUTH_CLIENTS=yes` and a private CA, a
260+
broker at an announced address that cannot present a certificate from your CA
261+
gets no connection and no message. Read [THREAT-MODEL.md](./THREAT-MODEL.md) for
262+
the rest.
263+
140264
### Persistence
141265

142266
`IMQ_PERSISTENCE=off` is the fastest and loses **every queued and every delayed
@@ -172,6 +296,12 @@ Three things are load-bearing; the rest are defaults:
172296
hardcoded;
173297
- **one password per fleet** — see *Authentication*.
174298

299+
And one thing a fork has to keep in step: **whichever port is listening is the
300+
port that must be announced**. Compose `tls-port` yourself and the announcer
301+
still reads the running config, so it follows — but hand-writing `port 0` while
302+
expecting `port` to be advertised is the failure this image now refuses to
303+
produce.
304+
175305
## Tags
176306

177307
`ghcr.io/imqueue/redis-broker:<redis-version>`, plus an immutable
@@ -189,9 +319,9 @@ pinned to it.
189319
Below 6.0 there is no ACL, so the config lock is unavailable; the image still
190320
runs and still sets the keyspace floor, and tells you what is unprotected.
191321

192-
*TLS is deliberately out of scope for now. `tls-port` plus certificate mounting
193-
is a configuration surface of its own, and it would not cover the discovery
194-
channel either way.*
322+
TLS needs a Redis built with `BUILD_TLS=yes`, which the official images are from
323+
**6.2** on. On a build without it the image refuses to start rather than letting
324+
Redis die on an unknown directive.
195325

196326
## Building
197327

THREAT-MODEL.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ or not.
1212
**Anyone who can send a UDP datagram to port 63000 on the discovery address can
1313
announce a broker `up`, or announce an existing broker `down`.** There is no
1414
signature, no shared secret and no sequence number on the datagram. It carries a
15-
name, a GUID, a status, a `host:port` and an interval, and any of them can be
16-
made up.
15+
name, a GUID, a status, a `host:port`, an interval and a transport marker, and
16+
any of them can be made up.
1717

1818
Reaching the port is the whole of the attack. Nothing else is required — no
1919
credential, no prior connection, and no read access to anything.
@@ -53,6 +53,10 @@ the first is much more likely than the second.
5353
5. **`IMQ_REQUIREPASS_FILE` set.** It does not protect discovery — see §4 — but
5454
it means a hostile broker cannot also be *read* by the fleet, and a real
5555
broker cannot be read by whoever found the port.
56+
6. **TLS with client certificates** (`IMQ_TLS_*`, `IMQ_TLS_AUTH_CLIENTS=yes`).
57+
It does not authenticate discovery either, and it is not required for the
58+
argument in §4 to hold — but it is the control that makes a hostile
59+
announcement close to useless: see §4.
5660

5761
## 4. Why this is acceptable
5862

@@ -71,8 +75,21 @@ the datagram carries no credential and is never authenticated, so a password
7175
turns "an attacker can read your queues" into "an attacker can disrupt your
7276
routing". Both are worth preventing, and the same control prevents them.
7377

78+
TLS does not change it either, but it moves the line further. With
79+
`IMQ_TLS_AUTH_CLIENTS=yes` and a CA that is yours, a broker announced at an
80+
attacker's address has to present a certificate signed by that CA before any
81+
client will send it a single message — so announcing a hostile broker stops
82+
being a way to *read* traffic and is only a way to *lose* it. The `down` attack
83+
in §2 is untouched: evicting a real broker needs no certificate.
84+
7485
## 5. What would change it
7586

87+
The `tls`/`plain` marker on the datagram is **not** a signal to trust. It says
88+
which port was announced, so an operator and a log can tell; no client turns
89+
encryption on or off because of it, and none should — that would let an unsigned
90+
UDP packet decide whether a connection is encrypted, which is the whole of this
91+
section in reverse.
92+
7693
There is **no signing, HMAC or nonce on the announcement today**, and adding one
7794
is not on the roadmap. It would need a shared secret distributed to every broker
7895
and every client, which is the same distribution problem as the Redis password

deploy/promoter/broker.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,19 +36,41 @@ spec:
3636
- { name: IMQ_PERSISTENCE, value: rdb }
3737
- name: IMQ_REQUIREPASS_FILE
3838
value: /run/secrets/imq/password
39+
# --- TLS. Uncomment these four blocks together (env, volumeMount,
40+
# volume, probe). The TLS listener REPLACES the plaintext one on the
41+
# same port 6379, so the Service, the NetworkPolicy and the port
42+
# names above stay exactly as they are, and the announcer advertises
43+
# 6379 as before - now marked `tls`.
44+
# - { name: IMQ_TLS_CERT_FILE, value: /run/tls/broker.crt }
45+
# - { name: IMQ_TLS_KEY_FILE, value: /run/tls/broker.key }
46+
# - { name: IMQ_TLS_CA_FILE, value: /run/tls/ca.crt }
3947
volumeMounts:
4048
- { name: secret, mountPath: /run/secrets/imq, readOnly: true }
4149
- { name: data, mountPath: /data }
50+
# - { name: tls, mountPath: /run/tls, readOnly: true }
4251
readinessProbe:
4352
exec:
4453
command: ["sh", "-c", "redis-cli -a \"$(cat /run/secrets/imq/password)\" --no-auth-warning ping"]
54+
# With TLS on, this command has to speak it too, or the pod never
55+
# becomes ready and the fleet never sees the broker:
56+
# redis-cli --tls --cert /run/tls/client.crt --key /run/tls/client.key
57+
# --cacert /run/tls/ca.crt --sni imq-broker.internal
58+
# -a "$(cat /run/secrets/imq/password)" --no-auth-warning ping
4559
initialDelaySeconds: 2
4660
volumes:
4761
# One password for the WHOLE fleet. Per-entry cluster credentials are
4862
# ignored client side, so a broker with its own secret is discovered and
4963
# then unreachable — see README, "Authentication".
5064
- name: secret
5165
secret: { secretName: imq-broker-password }
66+
# The certificate names the FLEET, not this pod: a broker's IP comes from
67+
# the scheduler, so no certificate can carry it. Issue one for
68+
# CN=imq-broker.internal and have clients pin it with
69+
# IMQ_REDIS_TLS_SERVERNAME - that name is compared, never resolved.
70+
# defaultMode 0444 matters: Redis reads the key as uid 999, after
71+
# dropping privileges, and a root-only 0400 mount is unreadable there.
72+
# - name: tls
73+
# secret: { secretName: imq-broker-tls, defaultMode: 0444 }
5274
volumeClaimTemplates:
5375
- metadata: { name: data }
5476
spec:

deploy/unicaster/broker.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,38 @@ spec:
4040
- { name: IMQ_PERSISTENCE, value: rdb }
4141
- name: IMQ_REQUIREPASS_FILE
4242
value: /run/secrets/imq/password
43+
# --- TLS. Uncomment these four blocks together (env, volumeMount,
44+
# volume, probe). The TLS listener REPLACES the plaintext one on the
45+
# same port 6379, so the Service, the NetworkPolicy and the port
46+
# names above stay exactly as they are, and the announcer advertises
47+
# 6379 as before - now marked `tls`.
48+
# - { name: IMQ_TLS_CERT_FILE, value: /run/tls/broker.crt }
49+
# - { name: IMQ_TLS_KEY_FILE, value: /run/tls/broker.key }
50+
# - { name: IMQ_TLS_CA_FILE, value: /run/tls/ca.crt }
4351
volumeMounts:
4452
- { name: secret, mountPath: /run/secrets/imq, readOnly: true }
4553
- { name: data, mountPath: /data }
54+
# - { name: tls, mountPath: /run/tls, readOnly: true }
4655
readinessProbe:
4756
exec:
4857
command: ["sh", "-c", "redis-cli -a \"$(cat /run/secrets/imq/password)\" --no-auth-warning ping"]
58+
# With TLS on, this command has to speak it too, or the pod never
59+
# becomes ready and the fleet never sees the broker:
60+
# redis-cli --tls --cert /run/tls/client.crt --key /run/tls/client.key
61+
# --cacert /run/tls/ca.crt --sni imq-broker.internal
62+
# -a "$(cat /run/secrets/imq/password)" --no-auth-warning ping
4963
initialDelaySeconds: 2
5064
volumes:
5165
- name: secret
5266
secret: { secretName: imq-broker-password }
67+
# The certificate names the FLEET, not this pod: a broker's IP comes from
68+
# the scheduler, so no certificate can carry it. Issue one for
69+
# CN=imq-broker.internal and have clients pin it with
70+
# IMQ_REDIS_TLS_SERVERNAME - that name is compared, never resolved.
71+
# defaultMode 0444 matters: Redis reads the key as uid 999, after
72+
# dropping privileges, and a root-only 0400 mount is unreadable there.
73+
# - name: tls
74+
# secret: { secretName: imq-broker-tls, defaultMode: 0444 }
5375
volumeClaimTemplates:
5476
- metadata: { name: data }
5577
spec:

0 commit comments

Comments
 (0)