Same structure as the bitmessage and bitcoind repositories: intro, usage with compose and cli examples, a parameter table, and notes carrying the traps — why the certificate must exist before start-up, why the runtime API is a unix socket, why there are two loggers, and why HSTS is one day rather than a year.
20 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. Задана — адрес посетителя заменяется на HMAC от него в X-Client-Id и дальше не передаётся; пуста — такой заголовок не отправляется. Создать: openssl rand -base64 32, см. «Замечания» |
Маршрутизация, тайм-ауты и настройки 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осознанно, если нужен полный журнал обращений: он же удерживает объём. - Логгеров два, и забытый второй сдаёт адреса.
option httplogздесь не используется: его формат по умолчанию начинается с%ci:%cp, то есть адреса посетителей попали бы вdocker logsи свели бы на нет псевдоним, который выставляют фронтенды. Собственныйlog-formatставит в это первое поле псевдоним. Ловушка —error-log-format: он покрывает то, что происходит до появления транзакции (отклонённое TLS-рукопожатие, а нижняя граница теперь TLS 1.2), и его умолчание начинается так же. Здесь заданы оба. Поэтому псевдоним вычисляется правиломtcp-request connectionна приёме соединения и в областиsess: правило http-фазы к моменту провала рукопожатия ещё не выполнялось бы. - В журнал идут только метод и путь, никогда не строка запроса.
%{+Q}rунёс бы и её, и токен, однажды оказавшийся в URL, был бы записан на всё время хранения журнала. - Адрес посетителя дальше не идёт.
option forwardforне задан:X-Forwarded-Forв обоих фронтендах удаляется и никогда не заполняется, поэтому ничто за этим прокси не может записать в журнал адрес, которого ему не давали. Вместо адреса передаётся псевдоним вX-Client-Id— HMAC-SHA256 от адреса на ключеXFF_HMAC_KEY. Он взаимно однозначен с адресом, то есть как ключ ограничения частоты ничем не хуже, и без ключа необратим. Именно HMAC, а не просто хеш: IPv4 — это 2³² значений, и хеш адреса без ключа перебирается за секунды. XFF_HMAC_KEYнеобязателен, и пустой ключ выключает функцию, а не ослабляет её. Не задан —X-Client-Idне отправляется вовсе, и ограничение частоты ниже по цепочке вырождается в одну корзину на всех посетителей; задан — у каждого посетителя своя. Чего не происходит никогда, так это псевдонима, выведенного на пустом ключе. Значение, не являющееся корректным base64, останавливает контейнер при разборе конфигурации — тихо испортиться оно не может. Ротация ключа сбрасывает корзины (пользователь этого не видит) и меняет все псевдонимы, поэтому активность до и после ротации связать нельзя.- Оба удаления безусловны.
X-Forwarded-ForиX-Client-Idудаляются независимо от того, задан ключ или нет, — чтобы присланный клиентом заголовок ниже по цепочке нельзя было принять за выставленный этим прокси. То же сX-Forwarded-Proto: каждый фронтенд выставляет собственную схему, а не передаёт дальше клиентское утверждение. - Потребителя всё равно нужно научить этим пользоваться. Ограничение
частоты, построенное на адресе сокета —
$binary_remote_addrу nginx,request.client.hostу ДС, — видит этот прокси на каждом запросе и вырождается в общую корзину. Ключом должен бытьX-Client-Id, и доверять ему следует только с адреса этого прокси; готовый пример —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:<версия>.