Files
haproxy/README.ru-RU.md
T
bitdeals 1f9452b773
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 37s
feat: generate XFF_HMAC_KEY when none is passed
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.
2026-08-10 14:50:57 +00:00

22 KiB
Raw Blame History

Общие сведения

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:<версия>.