@@ -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
6068The 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.
137145Auth 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.
189319Below 6.0 there is no ACL, so the config lock is unavailable; the image still
190320runs 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
0 commit comments