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.
22 KiB
Общие сведения
English version: README.md
HAProxy — балансировщик и обратный прокси для TCP и HTTP. Здесь он служит публичным краем сайта BitDeals: терминирует TLS на 443, передаёт всё остальное веб-контейнеру и направляет ACME-проверки в certbot.
HAProxy, работающий в docker-контейнере с конфигурацией, вшитой в образ.
Репозиторий описывает только развёртывание в docker. Образ — bitnami/haproxy, в который скопирован один файл.
Использование
У контейнера два порта, 80 и 443, и оба — публичный сайт.
Третий канал портом не является: runtime API HAProxy слушает unix-сокет
/var/lib/haproxy/admin.sock на томе, разделяемом с контейнером
certbot, — тот через него
устанавливает обновлённый сертификат в работающий процесс без перезапуска. API
имеет уровень admin и не защищён аутентификацией, поэтому кто может его
открыть, определяют права на файл, — см. «Замечания».
Переменная окружения одна — XFF_HMAC_KEY, и задавать её не нужно: если она не
задана, точка входа генерирует ключ сама. Всё остальное задано в
docker/haproxy.cfg, который копируется в образ при сборке, поэтому изменение
маршрутизации означает пересборку и повторное развёртывание.
Сертификат читается из /usr/local/etc/haproxy/certificates/site.pem, том
подключён только на чтение и разделяется с certbot. Файл обязан
существовать до старта контейнера — см. «Замечания».
docker-compose
services:
haproxy:
build:
context: https://git.bitdeals.org/private/haproxy.git
dockerfile: ./docker/Dockerfile
image: registry.bitdeals.org/haproxy
restart: unless-stopped
depends_on:
- nginx
- certbot
volumes:
- certificates:/usr/local/etc/haproxy/certificates:ro
- haproxy_admin:/var/lib/haproxy # сокет runtime API — только для certbot
ports:
- "80:80"
- "443:443"
volumes:
certificates:
haproxy_admin:
Оба бэкенда названы по сервисам, к которым обращаются: nginx:80 — сайт,
certbot:380 — ACME-проверки. Эти имена должны разрешаться внутри
compose-проекта, то есть названные сервисы обязаны быть с этим в одной сети.
docker cli
docker run -d \
-p 80:80 \
-p 443:443 \
-v certificates:/usr/local/etc/haproxy/certificates:ro \
registry.bitdeals.org/haproxy
Всё, что указано после имени образа, заменяет собственные аргументы демона, поэтому проверка подключённого файла конфигурации не требует нового образа:
docker run --rm -v "$PWD/docker/haproxy.cfg:/tmp/haproxy.cfg:ro" \
registry.bitdeals.org/haproxy -c -f /tmp/haproxy.cfg
Сборка и публикация
Push в main собирает и публикует образ (.gitea/workflows/build.yaml) с тремя
тегами: <версия>.<sha7> — для развёртывания, <версия> — для чтения и
latest — для compose и Watchtower. Ночной cron пересобирает образ из тех же
исходников. Вручную, если под рукой учётные данные реестра:
docker build . --file docker/Dockerfile --tag registry.bitdeals.org/haproxy
docker push registry.bitdeals.org/haproxy
Контекст сборки — корень репозитория, а не docker/: Dockerfile копирует
./docker/haproxy.cfg, поэтому при контексте ./docker этот файл не виден и
сборка падает на COPY.
Параметры
Образы контейнера настраиваются параметрами, передаваемыми при запуске.
| Параметр | Назначение |
|---|---|
| -p 80 | Обычный HTTP. Перенаправляет на HTTPS кодом 301, кроме пути ACME-проверки: он обязан оставаться доступным здесь, иначе перевыпуск не проходит |
| -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. IP посетителя заменяется на HMAC от него в X-Client-Id и дальше не передаётся. Не задавай — точка входа сгенерирует ключ на каждый запуск контейнера; задавай, только если псевдонимы должны пережить перезапуск или совпадать на двух прокси, см. «Замечания» |
Маршрутизация, тайм-ауты и настройки TLS параметрами не являются: они находятся
в docker/haproxy.cfg и поставляются внутри образа.
Замечания
site.pemобязан существовать до старта контейнера.bind ... ssl crtразбирается при чтении конфигурации, поэтому пустой том — это фатальная ошибка запуска, а не предупреждение: HAProxy завершается и без политики перезапуска больше не поднимается. certbot при первом старте создаёт самоподписанную заглушку именно чтобы разорвать этот круг, — поэтому сервис поставляется сrestart: unless-stopped; в проекте, где certbot определён, добавьте ещё иdepends_on.- Сертификат, установленный через runtime API, живёт только в памяти. Именно
поэтому том здесь подключён только на чтение:
set ssl certиcommit ssl certна диск ничего не пишут. Файл на томе — копия certbot, и именно её HAProxy перечитывает после перезапуска, так что оба пути согласуются без права записи у HAProxy. - Runtime API — полноценный административный канал без пароля. Любой, кто
способен его открыть, может установить другой сертификат с другим приватным
ключом, перенаправить бэкенд на иной адрес или вывести серверы из
обслуживания — то есть незаметно устроить сайту «человека посередине».
Считайте доступ к
admin.sockравноценным владению приватным ключом TLS и подключайте этот том к certbot и больше никуда.expose-fd listeners, которая вдобавок отдала бы клиенту сокета сами слушающие сокеты, намеренно не задана: она нужна для бесшовной перезагрузки, которой этот образ не выполняет. - Unix-сокет — потому что порт ограничить нельзя.
expose:наружу ничего не публикует, но и не ограничивает, а правил по портам у docker-сетей нет: значит runtime API на TCP открыт любому контейнеру в общей сети, включая nginx, — ведь HAProxy обязан дозваниваться до него. Сокет на томе доступен только тем контейнерам, которые этот том подключают, и это исчерпывающее описание разграничения доступа. Заодно приватный ключ, идущий по этому каналу при каждом перевыпуске, больше не покидает пределы тома. - HAProxy нужны права на запись в каталог сокета, а не только на сам файл.
Он связывается с сокетом, создавая
<путь>.<pid>.tmpи переименовывая его поверх целевого, — поэтому образ создаёт/var/lib/haproxyс владельцем 1001, а docker переносит это владение на пустой именованный том, подключённый сюда. Переименование заодно объясняет, почему оставшийся от прошлого запуска сокет безвреден. certbot подключается от root, иmode 660его не касается. - У редиректа на HTTPS есть одно исключение, и оно несущее. Порт 80
отвечает 301 на всё, кроме
/.well-known/acme-challenge/, — этот путь Let's Encrypt проверяет по обычному HTTP, и его перенаправление останавливает любой перевыпуск. Правило записано вышеuse_backend, потому что в этом порядке оно и выполняется: правилаhttp-requestвычисляются до выбора бэкенда независимо от порядка в файле, и HAProxy предупреждает, когда одно расходится с другим. - HSTS выставлен на сутки, а не на привычный год. Это дверь в одну сторону:
браузер, увидевший заголовок, отказывается ходить по обычному HTTP на этот
хост до истечения срока, и отменить это с сервера нельзя. Сутки оставляют
просроченный сертификат исправимым. Поднимать ступенями — 86400, 2592000,
31536000, — когда перевыпуск устоится.
includeSubDomainsиpreloadнамеренно отсутствуют: первый связывает имена, которых этот прокси не обслуживает, второй практически необратим. - Адреса бэкендов перечитываются, и по умолчанию это не так. Обе строки
serverнесутresolvers docker, поэтому именаnginxиcertbotразрешаются заново по ходу работы. Без этого имя разрешается один раз при загрузке и держится всё время жизни процесса, а пересозданный с новым IP контейнер — то, что Watchtower делает при каждом развёртывании, — остаётся незамеченным.init-addr libc,none— вторая половина: она позволяет HAProxy стартовать, когда бэкенд ещё не поднят, вместо отказа разобрать неразрешимое имя. - Журнал идёт в stdout, а
option dontlog-normalоставляет в нём только ошибки.log stdout format raw local0не требует syslog-демона — вывод забираетdocker logs. Успешный запрос не пишется ничем; 503, бэкенд без сервера, отклонённое рукопожатие — пишутся. Убирайтеdontlog-normalосознанно, если нужен полный журнал обращений: он же удерживает объём. - Логгеров два, и забытый второй сдаёт IP-адреса.
option httplogздесь не используется: его формат по умолчанию начинается с%ci:%cp, то есть IP-адреса посетителей попали бы вdocker logsи свели бы на нет псевдоним, который выставляют фронтенды. Собственныйlog-formatставит в это первое поле псевдоним. Ловушка —error-log-format: он покрывает то, что происходит до появления транзакции (отклонённое TLS-рукопожатие, а нижняя граница теперь TLS 1.2), и его умолчание начинается так же. Здесь заданы оба. Поэтому псевдоним вычисляется правиломtcp-request connectionна приёме соединения и в областиsess: правило http-фазы к моменту провала рукопожатия ещё не выполнялось бы. - В журнал идут только метод и путь, никогда не строка запроса.
%{+Q}rунёс бы и её, и токен, однажды оказавшийся в URL, был бы записан на всё время хранения журнала. - IP-адрес посетителя дальше не идёт.
option forwardforне задан:X-Forwarded-Forв обоих фронтендах удаляется и никогда не заполняется, поэтому ничто за этим прокси не может записать в журнал IP, которого ему не давали. Вместо IP-адреса передаётся псевдоним вX-Client-Id— HMAC-SHA256 от IP-адреса на ключеXFF_HMAC_KEY. Он взаимно однозначен с IP, то есть как ключ ограничения частоты ничем не хуже, и без ключа необратим. Именно HMAC, а не просто хеш: 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: каждый фронтенд выставляет собственную схему, а не передаёт дальше клиентское утверждение. - Потребителя всё равно нужно научить этим пользоваться. Ограничение
частоты, построенное на 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 выпускает ECDSA, а самоподписанная заглушка — RSA), билеты сессий выключены, чтобы совершенная прямая секретность не сводилась на нет долгоживущим ключом билета.alpn h2,http/1.1в строкеbindпредлагает браузерам HTTP/2; бэкенд остаётся на HTTP/1.1, преобразованием занимается HAProxy. Заголовок HSTS не отправляется, и это намеренно: он был бы преждевременным, пока порт 80 отдаёт сайт вместо перенаправления, а отменить его после того, как браузеры запомнили политику, трудно. - Фазу чтения заголовков ограничивает
timeout http-request 10s, иtimeout clientего не заменяет: тот является таймаутом бездействия и сбрасывается на каждом полученном байте, поэтому клиент, шлющий по байту, держит соединение открытым сколько угодно. Этот — абсолютный. - Процесс работает под uid 1001 и всё же занимает порты 80 и 443. Это
работает потому, что docker по умолчанию выставляет в контейнерах
net.ipv4.ip_unprivileged_port_start=0; хост или среда выполнения, вернувшие традиционное значение, приведут к тому, что контейнер не сможет занять порты. - Базовый образ не зафиксирован.
FROM bitnami/haproxyозначает:latest, а ночной cron пересборки берёт то, на что этот тег указывает сейчас, — минорная версия HAProxy может смениться в сборке, которую никто не запускал, после чего Watchtower выкатит её. Для воспроизводимых сборок фиксируйтеFROM bitnami/haproxy:<версия>.