feat: generate XFF_HMAC_KEY when none is passed
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 37s

An empty key switches the pseudonym off: no X-Client-Id is sent, and a
rate limit downstream falls back to one bucket shared by every visitor.
That is the one state nobody chooses on purpose and the easiest to reach
by forgetting a line in a .env — so the entrypoint now fills the key in
with `openssl rand -base64 32` when nothing else did.

Nobody picks this value, nothing outside the container needs to know it,
and no two deployments need the same one, which is what makes generating
it the right default rather than a convenience. A fresh key per container
start costs a reset of the downstream rate-limit buckets — invisible
against a one-minute window — and makes pseudonyms from before and after
unlinkable, which is the property the key exists for rather than a loss.
Passing one explicitly still wins, for whoever wants pseudonyms stable
across restarts or identical on two proxies.

base64 and not hex, deliberately: HAProxy's hmac() decodes the key as
base64, and hex would be accepted and silently decoded into something
else — a usable key, but it would quietly cost the guarantee that a
malformed one stops the container at configuration parsing.

The base image's entrypoint is the haproxy binary with no shell in
between, so the wrapper is the whole chain and execs the same binary with
the same arguments. CMD is restated rather than inherited.

Verified by building the image and checking the config in all three
states: unset (wrapper reports it generated one, config parses), set and
valid (wrapper silent, config parses), set and not base64 (`[ALERT]
invalid args in converter 'hmac' : failed to parse key`, container
refuses to start).

READMEs updated in both languages, and "address" is spelled "IP address"
throughout — it was never anything else.
This commit is contained in:
2026-08-10 14:50:57 +00:00
parent c063688b75
commit 1f9452b773
4 changed files with 111 additions and 51 deletions
+35 -26
View File
@@ -19,9 +19,10 @@ install a renewed certificate into the running process without a restart. The
API is `level admin` and has no authentication, so who can open it is decided by
file permissions — see Notes.
There is one environment variable, `XFF_HMAC_KEY`, and it is optional.
Everything else is in `docker/haproxy.cfg`, which is copied into the image at
build time, so changing the routing means rebuilding and redeploying.
There is one environment variable, `XFF_HMAC_KEY`, and it needs no setting: the
entrypoint generates one when it is absent. Everything else is in
`docker/haproxy.cfg`, which is copied into the image at build time, so changing
the routing means rebuilding and redeploying.
The certificate is read from `/usr/local/etc/haproxy/certificates/site.pem`,
mounted **read-only** from a volume shared with certbot. It must exist before
@@ -101,7 +102,7 @@ Container images are configured using parameters passed at runtime.
|-p 443|HTTPS. Needs `site.pem` in the certificates volume before the container starts|
|-v /usr/local/etc/haproxy/certificates|Certificate directory, read-only. Only `site.pem` is read, at bind time. certbot writes it through the same volume mounted read-write at `/etc/certificates`|
|-v /var/lib/haproxy|Runtime API socket (`admin.sock`, `level admin`, **no authentication**). Mount it into certbot and nothing else — see Notes|
|-e XFF_HMAC_KEY|Optional, base64. Set, the visitor's address is replaced by an HMAC of it in `X-Client-Id` and never passed on; empty, no such header is sent. Generate with `openssl rand -base64 32` — see Notes|
|-e XFF_HMAC_KEY|Optional, base64. The visitor's IP is replaced by an HMAC of it in `X-Client-Id` and never passed on. Leave it unset and the entrypoint generates one per container start; pass one only to keep pseudonyms stable across restarts or identical on two proxies — see Notes|
Routing, timeouts and TLS settings are not parameters: they live in
`docker/haproxy.cfg` and ship inside the image.
@@ -166,10 +167,10 @@ Routing, timeouts and TLS settings are not parameters: they live in
it. A successful request writes nothing; a 503, a backend with no server, a
refused handshake do. Drop `dontlog-normal` deliberately if a full access log
is wanted, and understand that it is also what keeps the volume down.
- **There are two loggers, and forgetting the second one leaks addresses.**
- **There are two loggers, and forgetting the second one leaks IP addresses.**
`option httplog` is never used: its default format opens with `%ci:%cp`, which
would put every visitor's address into `docker logs` and undo the pseudonym the
frontends mint. A hand-written `log-format` puts the pseudonym in that first
would put every visitor's IP address into `docker logs` and undo the pseudonym
the frontends mint. A hand-written `log-format` puts the pseudonym in that first
field instead. The trap is `error-log-format`, which covers what happens
*before* a transaction exists — a refused TLS handshake, and TLS 1.2 is now
the floor — and whose default opens the same way. Both are set here. The
@@ -179,32 +180,40 @@ Routing, timeouts and TLS settings are not parameters: they live in
- **Only the method and path are logged, never the query string.** `%{+Q}r`
would carry it, and a token that ever appeared in a URL would be written down
for as long as the log is kept.
- **The visitor's address stops here.** There is no `option forwardfor`:
- **The visitor's IP address stops here.** There is no `option forwardfor`:
`X-Forwarded-For` is deleted in both frontends and never filled in, so nothing
behind this proxy can log an address it was never given. `X-Client-Id` carries
a pseudonym instead — HMAC-SHA256 of the address under `XFF_HMAC_KEY`. Being
one-to-one with the address it is exactly as good a rate-limiting key, and
without the key it is not reversible. HMAC rather than a bare digest because
IPv4 is 2^32 values and an unkeyed hash of an address is brute-forced in
seconds.
- **`XFF_HMAC_KEY` is optional, and an empty one disables the feature rather
than weakening it.** Unset, no `X-Client-Id` is sent at all and a rate limit
downstream falls back to one bucket shared by every visitor; set, each visitor
gets their own. What never happens is a pseudonym derived from an empty key.
A value that is not valid base64 stops the container at configuration
parsing — it cannot degrade quietly. Rotating the key resets rate-limit
buckets (invisible to users) and changes every pseudonym, so activity either
side of a rotation cannot be linked.
behind this proxy can log an IP it was never given. `X-Client-Id` carries a
pseudonym instead — HMAC-SHA256 of the IP address under `XFF_HMAC_KEY`. Being
one-to-one with the IP it is exactly as good a rate-limiting key, and without
the key it is not reversible. HMAC rather than a bare digest because IPv4 is
2^32 values and an unkeyed hash of an IP address is brute-forced in seconds.
- **`XFF_HMAC_KEY` is generated when absent, not left empty.** An empty key
disables the feature — no `X-Client-Id` at all, and a rate limit downstream
falls back to one bucket shared by every visitor, which is the one state
nobody chooses on purpose and the easiest to reach by forgetting a line in a
`.env`. So the entrypoint fills it in with `openssl rand -base64 32` when
nothing else did. Nobody picks this value, nothing outside the container needs
to know it, and no two deployments need the same one.
A fresh key per container start costs a reset of the downstream rate-limit
buckets — invisible against a one-minute window — and makes pseudonyms from
before and after unlinkable, which is the property the key exists for rather
than a loss. Pass a value explicitly only to keep pseudonyms stable across
restarts, or identical on two proxies.
The config still handles an empty key, because `haproxy.cfg` can be run
outside this image. And a value that is not valid base64 still stops the
container at configuration parsing — it cannot degrade quietly, which is also
why the generated one is base64 and not hex: hex would be accepted here and
silently decoded as base64 into something else.
- **Both deletes are unconditional.** `X-Forwarded-For` and `X-Client-Id` are
dropped whether or not a key is configured, so a header a client sent can
never be mistaken downstream for one this proxy minted. Same for
`X-Forwarded-Proto`, which each frontend sets to its own scheme rather than
passing on the client's claim.
- **The consumer must still be told to use it.** A downstream rate limit keyed
on the socket address — nginx's `$binary_remote_addr`, ДС's
`request.client.host` — sees this proxy for every request and degenerates to
one shared bucket. It has to key on `X-Client-Id`, and trust that header only
from this proxy's address; `frontend/docker/rate-limit.conf` in the bitdeals-ng
on the socket IP address — nginx's `$binary_remote_addr`, ДС's
`request.client.host` — sees this proxy's IP for every request and degenerates
to one shared bucket. It has to key on `X-Client-Id`, and trust that header
only from this proxy's IP; `frontend/docker/rate-limit.conf` in the bitdeals-ng
repository is the worked example.
- **TLS is pinned in `global`, not left to OpenSSL.** TLS 1.2 is the floor,
the cipher list is ECDHE-only in both ECDSA and RSA variants — certbot issues