diff --git a/README.md b/README.md index 0d09753..2bf4c27 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.ru-RU.md b/README.ru-RU.md index 861f33a..bd3f7c9 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -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 diff --git a/docker/Dockerfile b/docker/Dockerfile index 24b01d9..a189958 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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"] + diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..a38339c --- /dev/null +++ b/docker/entrypoint.sh @@ -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 "$@"