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
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:
@@ -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
|
||||
|
||||
+35
-25
@@ -19,9 +19,10 @@ HAProxy, работающий в docker-контейнере с конфигур
|
||||
имеет уровень `admin` и не защищён аутентификацией, поэтому кто может его
|
||||
открыть, определяют права на файл, — см. «Замечания».
|
||||
|
||||
Переменная окружения одна — `XFF_HMAC_KEY`, и она необязательна. Всё остальное
|
||||
задано в `docker/haproxy.cfg`, который копируется в образ при сборке, поэтому
|
||||
изменение маршрутизации означает пересборку и повторное развёртывание.
|
||||
Переменная окружения одна — `XFF_HMAC_KEY`, и задавать её не нужно: если она не
|
||||
задана, точка входа генерирует ключ сама. Всё остальное задано в
|
||||
`docker/haproxy.cfg`, который копируется в образ при сборке, поэтому изменение
|
||||
маршрутизации означает пересборку и повторное развёртывание.
|
||||
|
||||
Сертификат читается из `/usr/local/etc/haproxy/certificates/site.pem`, том
|
||||
подключён **только на чтение** и разделяется с certbot. Файл обязан
|
||||
@@ -100,7 +101,7 @@ docker push registry.bitdeals.org/haproxy
|
||||
|-p 443|HTTPS. Требует наличия `site.pem` в томе сертификатов до старта контейнера|
|
||||
|-v /usr/local/etc/haproxy/certificates|Каталог сертификатов, только на чтение. Читается лишь `site.pem` и лишь при связывании портов. certbot пишет его через тот же том, подключённый на запись как `/etc/certificates`|
|
||||
|-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 параметрами не являются: они находятся
|
||||
в `docker/haproxy.cfg` и поставляются внутри образа.
|
||||
@@ -167,10 +168,10 @@ docker push registry.bitdeals.org/haproxy
|
||||
забирает `docker logs`. Успешный запрос не пишется ничем; 503, бэкенд без
|
||||
сервера, отклонённое рукопожатие — пишутся. Убирайте `dontlog-normal`
|
||||
осознанно, если нужен полный журнал обращений: он же удерживает объём.
|
||||
- **Логгеров два, и забытый второй сдаёт адреса.** `option httplog` здесь не
|
||||
используется: его формат по умолчанию начинается с `%ci:%cp`, то есть адреса
|
||||
посетителей попали бы в `docker logs` и свели бы на нет псевдоним, который
|
||||
выставляют фронтенды. Собственный `log-format` ставит в это первое поле
|
||||
- **Логгеров два, и забытый второй сдаёт IP-адреса.** `option httplog` здесь не
|
||||
используется: его формат по умолчанию начинается с `%ci:%cp`, то есть
|
||||
IP-адреса посетителей попали бы в `docker logs` и свели бы на нет псевдоним,
|
||||
который выставляют фронтенды. Собственный `log-format` ставит в это первое поле
|
||||
псевдоним. Ловушка — `error-log-format`: он покрывает то, что происходит *до*
|
||||
появления транзакции (отклонённое TLS-рукопожатие, а нижняя граница теперь
|
||||
TLS 1.2), и его умолчание начинается так же. Здесь заданы оба. Поэтому
|
||||
@@ -180,32 +181,41 @@ docker push registry.bitdeals.org/haproxy
|
||||
- **В журнал идут только метод и путь, никогда не строка запроса.** `%{+Q}r`
|
||||
унёс бы и её, и токен, однажды оказавшийся в URL, был бы записан на всё время
|
||||
хранения журнала.
|
||||
- **Адрес посетителя дальше не идёт.** `option forwardfor` не задан:
|
||||
- **IP-адрес посетителя дальше не идёт.** `option forwardfor` не задан:
|
||||
`X-Forwarded-For` в обоих фронтендах удаляется и никогда не заполняется,
|
||||
поэтому ничто за этим прокси не может записать в журнал адрес, которого ему не
|
||||
давали. Вместо адреса передаётся псевдоним в `X-Client-Id` — HMAC-SHA256 от
|
||||
адреса на ключе `XFF_HMAC_KEY`. Он взаимно однозначен с адресом, то есть как
|
||||
поэтому ничто за этим прокси не может записать в журнал IP, которого ему не
|
||||
давали. Вместо IP-адреса передаётся псевдоним в `X-Client-Id` — HMAC-SHA256 от
|
||||
IP-адреса на ключе `XFF_HMAC_KEY`. Он взаимно однозначен с IP, то есть как
|
||||
ключ ограничения частоты ничем не хуже, и без ключа необратим. Именно HMAC, а
|
||||
не просто хеш: IPv4 — это 2³² значений, и хеш адреса без ключа перебирается за
|
||||
секунды.
|
||||
- **`XFF_HMAC_KEY` необязателен, и пустой ключ выключает функцию, а не
|
||||
ослабляет её.** Не задан — `X-Client-Id` не отправляется вовсе, и ограничение
|
||||
частоты ниже по цепочке вырождается в одну корзину на всех посетителей;
|
||||
задан — у каждого посетителя своя. Чего не происходит никогда, так это
|
||||
псевдонима, выведенного на пустом ключе. Значение, не являющееся корректным
|
||||
base64, останавливает контейнер при разборе конфигурации — тихо испортиться
|
||||
оно не может. Ротация ключа сбрасывает корзины (пользователь этого не видит) и
|
||||
меняет все псевдонимы, поэтому активность до и после ротации связать нельзя.
|
||||
не просто хеш: IPv4 — это 2³² значений, и хеш IP-адреса без ключа перебирается
|
||||
за секунды.
|
||||
- **`XFF_HMAC_KEY` не оставляется пустым, а генерируется.** Пустой ключ
|
||||
выключает функцию: `X-Client-Id` не отправляется вовсе, и ограничение частоты
|
||||
ниже по цепочке вырождается в одну корзину на всех посетителей — состояние,
|
||||
которого никто не выбирает нарочно и в которое проще всего попасть, забыв
|
||||
строку в `.env`. Поэтому точка входа подставляет `openssl rand -base64 32`,
|
||||
если ключа нет. Его значение никто не выбирает, снаружи контейнера оно никому
|
||||
не нужно, и двум развёртываниям не требуется одинаковое.
|
||||
Новый ключ на каждый запуск контейнера стоит сброса корзин ниже по цепочке —
|
||||
на минутном окне это незаметно — и делает несвязываемыми псевдонимы до и
|
||||
после, а это свойство, ради которого ключ и существует, а не потеря. Задавать
|
||||
значение явно стоит лишь тогда, когда псевдонимы должны пережить перезапуск
|
||||
или совпадать на двух прокси.
|
||||
Конфигурация по-прежнему умеет работать с пустым ключом: `haproxy.cfg` можно
|
||||
запустить и вне этого образа. Значение, не являющееся корректным base64,
|
||||
по-прежнему останавливает контейнер при разборе — тихо испортиться оно не
|
||||
может, и поэтому же генерируется base64, а не hex: hex здесь был бы принят и
|
||||
молча раскодирован как base64 во что-то другое.
|
||||
- **Оба удаления безусловны.** `X-Forwarded-For` и `X-Client-Id` удаляются
|
||||
независимо от того, задан ключ или нет, — чтобы присланный клиентом заголовок
|
||||
ниже по цепочке нельзя было принять за выставленный этим прокси. То же с
|
||||
`X-Forwarded-Proto`: каждый фронтенд выставляет собственную схему, а не
|
||||
передаёт дальше клиентское утверждение.
|
||||
- **Потребителя всё равно нужно научить этим пользоваться.** Ограничение
|
||||
частоты, построенное на адресе сокета — `$binary_remote_addr` у nginx,
|
||||
`request.client.host` у ДС, — видит этот прокси на каждом запросе и
|
||||
частоты, построенное на IP-адресе сокета — `$binary_remote_addr` у nginx,
|
||||
`request.client.host` у ДС, — видит IP этого прокси на каждом запросе и
|
||||
вырождается в общую корзину. Ключом должен быть `X-Client-Id`, и доверять ему
|
||||
следует только с адреса этого прокси; готовый пример —
|
||||
следует только с IP этого прокси; готовый пример —
|
||||
`frontend/docker/rate-limit.conf` в репозитории bitdeals-ng.
|
||||
- **TLS задан в `global`, а не отдан на усмотрение OpenSSL.** Нижняя граница —
|
||||
TLS 1.2, список шифров только ECDHE и в вариантах ECDSA и RSA (certbot
|
||||
|
||||
@@ -16,3 +16,13 @@ USER root
|
||||
RUN mkdir -p /var/lib/haproxy && chown 1001:1001 /var/lib/haproxy
|
||||
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"]
|
||||
|
||||
|
||||
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user