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 API is `level admin` and has no authentication, so who can open it is decided by
file permissions — see Notes. file permissions — see Notes.
There is one environment variable, `XFF_HMAC_KEY`, and it is optional. There is one environment variable, `XFF_HMAC_KEY`, and it needs no setting: the
Everything else is in `docker/haproxy.cfg`, which is copied into the image at entrypoint generates one when it is absent. Everything else is in
build time, so changing the routing means rebuilding and redeploying. `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`, 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 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| |-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 /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| |-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 Routing, timeouts and TLS settings are not parameters: they live in
`docker/haproxy.cfg` and ship inside the image. `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 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 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. 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 `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 would put every visitor's IP address into `docker logs` and undo the pseudonym
frontends mint. A hand-written `log-format` puts the pseudonym in that first 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 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 *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 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` - **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 would carry it, and a token that ever appeared in a URL would be written down
for as long as the log is kept. 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 `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 behind this proxy can log an IP it was never given. `X-Client-Id` carries a
a pseudonym instead — HMAC-SHA256 of the address under `XFF_HMAC_KEY`. Being pseudonym instead — HMAC-SHA256 of the IP address under `XFF_HMAC_KEY`. Being
one-to-one with the address it is exactly as good a rate-limiting key, and one-to-one with the IP it is exactly as good a rate-limiting key, and without
without the key it is not reversible. HMAC rather than a bare digest because the key it is not reversible. HMAC rather than a bare digest because IPv4 is
IPv4 is 2^32 values and an unkeyed hash of an address is brute-forced in 2^32 values and an unkeyed hash of an IP address is brute-forced in seconds.
seconds. - **`XFF_HMAC_KEY` is generated when absent, not left empty.** An empty key
- **`XFF_HMAC_KEY` is optional, and an empty one disables the feature rather disables the feature — no `X-Client-Id` at all, and a rate limit downstream
than weakening it.** Unset, no `X-Client-Id` is sent at all and a rate limit falls back to one bucket shared by every visitor, which is the one state
downstream falls back to one bucket shared by every visitor; set, each visitor nobody chooses on purpose and the easiest to reach by forgetting a line in a
gets their own. What never happens is a pseudonym derived from an empty key. `.env`. So the entrypoint fills it in with `openssl rand -base64 32` when
A value that is not valid base64 stops the container at configuration nothing else did. Nobody picks this value, nothing outside the container needs
parsing — it cannot degrade quietly. Rotating the key resets rate-limit to know it, and no two deployments need the same one.
buckets (invisible to users) and changes every pseudonym, so activity either A fresh key per container start costs a reset of the downstream rate-limit
side of a rotation cannot be linked. 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 - **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 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 never be mistaken downstream for one this proxy minted. Same for
`X-Forwarded-Proto`, which each frontend sets to its own scheme rather than `X-Forwarded-Proto`, which each frontend sets to its own scheme rather than
passing on the client's claim. passing on the client's claim.
- **The consumer must still be told to use it.** A downstream rate limit keyed - **The consumer must still be told to use it.** A downstream rate limit keyed
on the socket address — nginx's `$binary_remote_addr`, ДС's on the socket IP address — nginx's `$binary_remote_addr`, ДС's
`request.client.host` — sees this proxy for every request and degenerates to `request.client.host` — sees this proxy's IP for every request and degenerates
one shared bucket. It has to key on `X-Client-Id`, and trust that header only to one shared bucket. It has to key on `X-Client-Id`, and trust that header
from this proxy's address; `frontend/docker/rate-limit.conf` in the bitdeals-ng only from this proxy's IP; `frontend/docker/rate-limit.conf` in the bitdeals-ng
repository is the worked example. repository is the worked example.
- **TLS is pinned in `global`, not left to OpenSSL.** TLS 1.2 is the floor, - **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 the cipher list is ECDHE-only in both ECDSA and RSA variants — certbot issues
+35 -25
View File
@@ -19,9 +19,10 @@ HAProxy, работающий в docker-контейнере с конфигур
имеет уровень `admin` и не защищён аутентификацией, поэтому кто может его имеет уровень `admin` и не защищён аутентификацией, поэтому кто может его
открыть, определяют права на файл, — см. «Замечания». открыть, определяют права на файл, — см. «Замечания».
Переменная окружения одна — `XFF_HMAC_KEY`, и она необязательна. Всё остальное Переменная окружения одна — `XFF_HMAC_KEY`, и задавать её не нужно: если она не
задано в `docker/haproxy.cfg`, который копируется в образ при сборке, поэтому задана, точка входа генерирует ключ сама. Всё остальное задано в
изменение маршрутизации означает пересборку и повторное развёртывание. `docker/haproxy.cfg`, который копируется в образ при сборке, поэтому изменение
маршрутизации означает пересборку и повторное развёртывание.
Сертификат читается из `/usr/local/etc/haproxy/certificates/site.pem`, том Сертификат читается из `/usr/local/etc/haproxy/certificates/site.pem`, том
подключён **только на чтение** и разделяется с certbot. Файл обязан подключён **только на чтение** и разделяется с certbot. Файл обязан
@@ -100,7 +101,7 @@ docker push registry.bitdeals.org/haproxy
|-p 443|HTTPS. Требует наличия `site.pem` в томе сертификатов до старта контейнера| |-p 443|HTTPS. Требует наличия `site.pem` в томе сертификатов до старта контейнера|
|-v /usr/local/etc/haproxy/certificates|Каталог сертификатов, только на чтение. Читается лишь `site.pem` и лишь при связывании портов. certbot пишет его через тот же том, подключённый на запись как `/etc/certificates`| |-v /usr/local/etc/haproxy/certificates|Каталог сертификатов, только на чтение. Читается лишь `site.pem` и лишь при связывании портов. certbot пишет его через тот же том, подключённый на запись как `/etc/certificates`|
|-v /var/lib/haproxy|Сокет runtime API (`admin.sock`, уровень `admin`, **без аутентификации**). Подключайте этот том к certbot и больше никуда — см. «Замечания»| |-v /var/lib/haproxy|Сокет runtime API (`admin.sock`, уровень `admin`, **без аутентификации**). Подключайте этот том к certbot и больше никуда — см. «Замечания»|
|-e XFF_HMAC_KEY|Необязательная, base64. Задана — адрес посетителя заменяется на HMAC от него в `X-Client-Id` и дальше не передаётся; пуста — такой заголовок не отправляется. Создать: `openssl rand -base64 32`, см. «Замечания»| |-e XFF_HMAC_KEY|Необязательная, base64. IP посетителя заменяется на HMAC от него в `X-Client-Id` и дальше не передаётся. Не задавай — точка входа сгенерирует ключ на каждый запуск контейнера; задавай, только если псевдонимы должны пережить перезапуск или совпадать на двух прокси, см. «Замечания»|
Маршрутизация, тайм-ауты и настройки TLS параметрами не являются: они находятся Маршрутизация, тайм-ауты и настройки TLS параметрами не являются: они находятся
в `docker/haproxy.cfg` и поставляются внутри образа. в `docker/haproxy.cfg` и поставляются внутри образа.
@@ -167,10 +168,10 @@ docker push registry.bitdeals.org/haproxy
забирает `docker logs`. Успешный запрос не пишется ничем; 503, бэкенд без забирает `docker logs`. Успешный запрос не пишется ничем; 503, бэкенд без
сервера, отклонённое рукопожатие — пишутся. Убирайте `dontlog-normal` сервера, отклонённое рукопожатие — пишутся. Убирайте `dontlog-normal`
осознанно, если нужен полный журнал обращений: он же удерживает объём. осознанно, если нужен полный журнал обращений: он же удерживает объём.
- **Логгеров два, и забытый второй сдаёт адреса.** `option httplog` здесь не - **Логгеров два, и забытый второй сдаёт IP-адреса.** `option httplog` здесь не
используется: его формат по умолчанию начинается с `%ci:%cp`, то есть адреса используется: его формат по умолчанию начинается с `%ci:%cp`, то есть
посетителей попали бы в `docker logs` и свели бы на нет псевдоним, который IP-адреса посетителей попали бы в `docker logs` и свели бы на нет псевдоним,
выставляют фронтенды. Собственный `log-format` ставит в это первое поле который выставляют фронтенды. Собственный `log-format` ставит в это первое поле
псевдоним. Ловушка — `error-log-format`: он покрывает то, что происходит *до* псевдоним. Ловушка — `error-log-format`: он покрывает то, что происходит *до*
появления транзакции (отклонённое TLS-рукопожатие, а нижняя граница теперь появления транзакции (отклонённое TLS-рукопожатие, а нижняя граница теперь
TLS 1.2), и его умолчание начинается так же. Здесь заданы оба. Поэтому TLS 1.2), и его умолчание начинается так же. Здесь заданы оба. Поэтому
@@ -180,32 +181,41 @@ docker push registry.bitdeals.org/haproxy
- **В журнал идут только метод и путь, никогда не строка запроса.** `%{+Q}r` - **В журнал идут только метод и путь, никогда не строка запроса.** `%{+Q}r`
унёс бы и её, и токен, однажды оказавшийся в URL, был бы записан на всё время унёс бы и её, и токен, однажды оказавшийся в URL, был бы записан на всё время
хранения журнала. хранения журнала.
- **Адрес посетителя дальше не идёт.** `option forwardfor` не задан: - **IP-адрес посетителя дальше не идёт.** `option forwardfor` не задан:
`X-Forwarded-For` в обоих фронтендах удаляется и никогда не заполняется, `X-Forwarded-For` в обоих фронтендах удаляется и никогда не заполняется,
поэтому ничто за этим прокси не может записать в журнал адрес, которого ему не поэтому ничто за этим прокси не может записать в журнал IP, которого ему не
давали. Вместо адреса передаётся псевдоним в `X-Client-Id` — HMAC-SHA256 от давали. Вместо IP-адреса передаётся псевдоним в `X-Client-Id` — HMAC-SHA256 от
адреса на ключе `XFF_HMAC_KEY`. Он взаимно однозначен с адресом, то есть как IP-адреса на ключе `XFF_HMAC_KEY`. Он взаимно однозначен с IP, то есть как
ключ ограничения частоты ничем не хуже, и без ключа необратим. Именно HMAC, а ключ ограничения частоты ничем не хуже, и без ключа необратим. Именно HMAC, а
не просто хеш: IPv4 — это 2³² значений, и хеш адреса без ключа перебирается за не просто хеш: IPv4 — это 2³² значений, и хеш IP-адреса без ключа перебирается
секунды. за секунды.
- **`XFF_HMAC_KEY` необязателен, и пустой ключ выключает функцию, а не - **`XFF_HMAC_KEY` не оставляется пустым, а генерируется.** Пустой ключ
ослабляет её.** Не задан — `X-Client-Id` не отправляется вовсе, и ограничение выключает функцию: `X-Client-Id` не отправляется вовсе, и ограничение частоты
частоты ниже по цепочке вырождается в одну корзину на всех посетителей; ниже по цепочке вырождается в одну корзину на всех посетителей — состояние,
задан — у каждого посетителя своя. Чего не происходит никогда, так это которого никто не выбирает нарочно и в которое проще всего попасть, забыв
псевдонима, выведенного на пустом ключе. Значение, не являющееся корректным строку в `.env`. Поэтому точка входа подставляет `openssl rand -base64 32`,
base64, останавливает контейнер при разборе конфигурации — тихо испортиться если ключа нет. Его значение никто не выбирает, снаружи контейнера оно никому
оно не может. Ротация ключа сбрасывает корзины (пользователь этого не видит) и не нужно, и двум развёртываниям не требуется одинаковое.
меняет все псевдонимы, поэтому активность до и после ротации связать нельзя. Новый ключ на каждый запуск контейнера стоит сброса корзин ниже по цепочке —
на минутном окне это незаметно — и делает несвязываемыми псевдонимы до и
после, а это свойство, ради которого ключ и существует, а не потеря. Задавать
значение явно стоит лишь тогда, когда псевдонимы должны пережить перезапуск
или совпадать на двух прокси.
Конфигурация по-прежнему умеет работать с пустым ключом: `haproxy.cfg` можно
запустить и вне этого образа. Значение, не являющееся корректным base64,
по-прежнему останавливает контейнер при разборе — тихо испортиться оно не
может, и поэтому же генерируется base64, а не hex: hex здесь был бы принят и
молча раскодирован как base64 во что-то другое.
- **Оба удаления безусловны.** `X-Forwarded-For` и `X-Client-Id` удаляются - **Оба удаления безусловны.** `X-Forwarded-For` и `X-Client-Id` удаляются
независимо от того, задан ключ или нет, — чтобы присланный клиентом заголовок независимо от того, задан ключ или нет, — чтобы присланный клиентом заголовок
ниже по цепочке нельзя было принять за выставленный этим прокси. То же с ниже по цепочке нельзя было принять за выставленный этим прокси. То же с
`X-Forwarded-Proto`: каждый фронтенд выставляет собственную схему, а не `X-Forwarded-Proto`: каждый фронтенд выставляет собственную схему, а не
передаёт дальше клиентское утверждение. передаёт дальше клиентское утверждение.
- **Потребителя всё равно нужно научить этим пользоваться.** Ограничение - **Потребителя всё равно нужно научить этим пользоваться.** Ограничение
частоты, построенное на адресе сокета — `$binary_remote_addr` у nginx, частоты, построенное на IP-адресе сокета — `$binary_remote_addr` у nginx,
`request.client.host` у ДС, — видит этот прокси на каждом запросе и `request.client.host` у ДС, — видит IP этого прокси на каждом запросе и
вырождается в общую корзину. Ключом должен быть `X-Client-Id`, и доверять ему вырождается в общую корзину. Ключом должен быть `X-Client-Id`, и доверять ему
следует только с адреса этого прокси; готовый пример — следует только с IP этого прокси; готовый пример —
`frontend/docker/rate-limit.conf` в репозитории bitdeals-ng. `frontend/docker/rate-limit.conf` в репозитории bitdeals-ng.
- **TLS задан в `global`, а не отдан на усмотрение OpenSSL.** Нижняя граница — - **TLS задан в `global`, а не отдан на усмотрение OpenSSL.** Нижняя граница —
TLS 1.2, список шифров только ECDHE и в вариантах ECDSA и RSA (certbot TLS 1.2, список шифров только ECDHE и в вариантах ECDSA и RSA (certbot
+10
View File
@@ -16,3 +16,13 @@ USER root
RUN mkdir -p /var/lib/haproxy && chown 1001:1001 /var/lib/haproxy RUN mkdir -p /var/lib/haproxy && chown 1001:1001 /var/lib/haproxy
USER 1001 USER 1001
# The base image's entrypoint is the haproxy binary itself, with no shell in
# between, so this wrapper is the whole chain: it fills in XFF_HMAC_KEY when
# nothing else did (see the script for why that is better than requiring it) and
# execs the same binary with the same arguments. CMD is restated rather than
# left to inheritance — it would be inherited, but a changed ENTRYPOINT is
# exactly where that stops being obvious to the next reader.
COPY --chmod=0755 ./docker/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["-f", "/bitnami/haproxy/conf/haproxy.cfg"]
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
set -eu
# XFF_HMAC_KEY keys the HMAC that turns a visitor's IP into the X-Client-Id this
# proxy passes downstream. Nobody picks its value, nothing outside this container
# ever needs to know it, and no two deployments need the same one — so an unset
# key is generated here rather than demanded from a .env file. Absent, the
# feature would switch itself off and every visitor downstream would share one
# rate-limit bucket, which is the one state nobody wants and the easiest to end
# up in by forgetting a line.
#
# What a fresh key per container start costs: the downstream rate-limit buckets
# reset — invisible against a one-minute window — and pseudonyms seen before and
# after cannot be linked, which is the property the key exists for rather than a
# loss. It costs nothing else: the key is never stored, compared or shared.
#
# base64, because that is what HAProxy's hmac() converter decodes. Hex would be
# accepted here and silently decoded as base64 into something else — valid as a
# key, but it would quietly break the guarantee that a malformed key stops the
# container at configuration parsing instead of degrading.
#
# Set it explicitly and this does nothing: an operator who wants a stable
# pseudonym across restarts, or the same one on two proxies, still just passes
# the variable in.
if [ -z "${XFF_HMAC_KEY:-}" ]; then
XFF_HMAC_KEY="$(openssl rand -base64 32)"
export XFF_HMAC_KEY
echo "XFF_HMAC_KEY was not set — generated one for this container." >&2
fi
exec /opt/bitnami/haproxy/sbin/haproxy "$@"